From 93decdbcb691eefa6b09b97433bc20296a72b8f9 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 00:34:32 +0900 Subject: [PATCH 01/57] feat: update null safety annotations and improve validation messages in show registration --- build.gradle | 3 +++ src/main/java/org/mandarin/booking/app/package-info.java | 5 +++-- .../java/org/mandarin/booking/domain/AbstractEntity.java | 2 +- .../org/mandarin/booking/domain/member/package-info.java | 5 +++-- src/main/java/org/mandarin/booking/domain/package-info.java | 4 ++-- .../mandarin/booking/domain/show/ShowRegisterRequest.java | 6 +++--- .../java/org/mandarin/booking/domain/show/package-info.java | 4 ++++ 7 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/domain/show/package-info.java diff --git a/build.gradle b/build.gradle index 3b64665..9bde339 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,9 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' byteBuddyAgent 'net.bytebuddy:byte-buddy-agent:1.17.6' testImplementation 'org.mockito:mockito-inline:5.2.0' + + // null safety + implementation 'org.jspecify:jspecify:1.0.0' } tasks.named('test') { diff --git a/src/main/java/org/mandarin/booking/app/package-info.java b/src/main/java/org/mandarin/booking/app/package-info.java index 626994a..03fd0c8 100644 --- a/src/main/java/org/mandarin/booking/app/package-info.java +++ b/src/main/java/org/mandarin/booking/app/package-info.java @@ -1,3 +1,4 @@ -@NonNullApi +@NullMarked package org.mandarin.booking.app; -import org.springframework.lang.NonNullApi; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/org/mandarin/booking/domain/AbstractEntity.java b/src/main/java/org/mandarin/booking/domain/AbstractEntity.java index 1425846..38d560d 100644 --- a/src/main/java/org/mandarin/booking/domain/AbstractEntity.java +++ b/src/main/java/org/mandarin/booking/domain/AbstractEntity.java @@ -2,7 +2,6 @@ import static jakarta.persistence.GenerationType.IDENTITY; -import jakarta.annotation.Nullable; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; @@ -10,6 +9,7 @@ import lombok.Getter; import lombok.ToString; import org.hibernate.proxy.HibernateProxy; +import org.jspecify.annotations.Nullable; @MappedSuperclass @ToString(callSuper = true) diff --git a/src/main/java/org/mandarin/booking/domain/member/package-info.java b/src/main/java/org/mandarin/booking/domain/member/package-info.java index 669813d..f062ab6 100644 --- a/src/main/java/org/mandarin/booking/domain/member/package-info.java +++ b/src/main/java/org/mandarin/booking/domain/member/package-info.java @@ -1,3 +1,4 @@ -@NonNullApi +@NullMarked package org.mandarin.booking.domain.member; -import org.springframework.lang.NonNullApi; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/org/mandarin/booking/domain/package-info.java b/src/main/java/org/mandarin/booking/domain/package-info.java index d524781..350d703 100644 --- a/src/main/java/org/mandarin/booking/domain/package-info.java +++ b/src/main/java/org/mandarin/booking/domain/package-info.java @@ -1,4 +1,4 @@ -@NonNullApi +@NullMarked package org.mandarin.booking.domain; -import org.springframework.lang.NonNullApi; +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index b679618..0c6c920 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -26,11 +26,11 @@ public record ShowRegisterRequest( @NotBlank(message = "posterUrl is required") String posterUrl, - @NotNull(message = "performanceStartDate is required") - @FutureOrPresent(message = "performanceStartDate must be today or future") + @NotNull(message = "performance start date is required") + @FutureOrPresent(message = "performance start date must be today or future") LocalDate performanceStartDate, - @NotNull(message = "performanceEndDate is required") + @NotNull(message = "performance end date is required") LocalDate performanceEndDate ) { } diff --git a/src/main/java/org/mandarin/booking/domain/show/package-info.java b/src/main/java/org/mandarin/booking/domain/show/package-info.java new file mode 100644 index 0000000..5197811 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.mandarin.booking.domain.show; + +import org.jspecify.annotations.NullMarked; From ed482a3380c1bf694f5f342a02b1ebc7d99a2058 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 00:38:00 +0900 Subject: [PATCH 02/57] feat: replace SpotBugs with Spring Boot DevTools in build configuration --- build.gradle | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 9bde339..7676b34 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,6 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.4' id 'io.spring.dependency-management' version '1.1.7' - id 'com.github.spotbugs' version '6.2.4' } group = 'org.mandarin' @@ -49,9 +48,7 @@ dependencies { // ---- Dev Only ---- developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - - // ---- Code Quality / Tooling ---- - spotbugs 'com.github.spotbugs:spotbugs:4.9.3' + developmentOnly 'org.springframework.boot:spring-boot-devtools' // ---- Testing ---- testImplementation 'org.springframework.boot:spring-boot-starter-test' From 5a272a4e641c7f88f4a8c9dce3c2945e627dfd82 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 09:34:30 +0900 Subject: [PATCH 03/57] feat: update show registration to use ADMIN authority and enhance request documentation --- docs/specs/api/show_register.md | 41 ++++++++++--------- .../adapter/security/SecurityConfig.java | 2 +- .../booking/webapi/show/POST_specs.java | 14 +++---- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index da21903..e9d6d19 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -12,7 +12,15 @@ - 본문 ```json - + { + "title": "인셉션", + "type": "MUSICAL", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "performanceStartDate": "2024-07-01", + "performanceEndDate": "2024-07-31" + } ``` @@ -23,20 +31,14 @@ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9ESVNUUklCVVRPUiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU2NDM4MjIzLCJleHAiOjE3NTY0Mzg4MjN9.DN0wZb8BdKY-7Grd0KAALXf88KX3iF_tg6UmcfotkFOlbRoRnSuY1nNVUFfZk2TxP0hvju3A8AglK3mt_hnutQ' \ -H 'Content-Type: application/json' \ -d '{ - "title": "인셉션", - "director": "크리스토퍼 놀란", - "runtimeMinutes": 148, - "genre": "SF", - "releaseDate": "2010-07-21", - "rating": "AGE12", - "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "posterUrl": "https://example.com/posters/inception.jpg", - "casts": [ - "레오나르도 디카프리오", - "조셉 고든레빗", - "엘렌 페이지" - ] - }' + "title": "인셉션", + "type": "MUSICAL", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "performanceStartDate": "2024-07-01", + "performanceEndDate": "2024-07-31" + }' ``` ### 응답 @@ -57,8 +59,9 @@ ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 -- [ ] 올바른 요청을 보내면 응답 본문에 showId가 존재한다 -- [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 +- [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 -- [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 -- [x] releaseDate는 yyyy-MM-dd 형태를 준수하지 않으면 BAD_REQUEST이다 +- [x] 허용되지 않은 타입이면 BAD_REQUEST이다 +- [x] 올바른 요청을 보내면 응답 본문에 showId가 존재한다 +- [x] 공연 시작일은 공연 종료일 이후면 INTERNAL_SERVER_ERROR이다 +- [x] 중복된 제목의 공연을 등록하면 INTERNAL_SERVER_ERROR가 발생한다 diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index af14a58..8e06ade 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -39,7 +39,7 @@ public SecurityFilterChain apiChain(HttpSecurity http, .requestMatchers(HttpMethod.POST, "/api/member").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_DISTRIBUTOR") + .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 5d2a8b7..0c4751c 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -5,7 +5,7 @@ import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; -import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; +import static org.mandarin.booking.domain.member.MemberAuthority.ADMIN; import java.time.LocalDate; import java.util.List; @@ -29,7 +29,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); var request = validShowRegisterRequest(); // Act @@ -69,7 +69,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); // Act var response = testUtils.post( @@ -88,7 +88,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); var request = new ShowRegisterRequest( "공연 제목", "MOVIE", // invalid type @@ -116,7 +116,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); var request = validShowRegisterRequest(); // Act @@ -136,7 +136,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); var request = new ShowRegisterRequest( "공연 제목", "MUSICAL", @@ -166,7 +166,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); var request = validShowRegisterRequest(); testUtils.post( "/api/show", From 0b917f2f549420fececa5d922e48347ec8ce4a7b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 16:15:59 +0900 Subject: [PATCH 04/57] feat: add src/docs/ to .gitignore to exclude documentation files from version control --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c771de5..88538ea 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /build .idea .aiassistant/rules/AGENTS.md +/src/docs/ From 386285ca27b998b16a65dff6782ff65f8511dda4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 16:24:36 +0900 Subject: [PATCH 05/57] feat: integrate Spring REST Docs for API documentation and enhance test utilities --- build.gradle | 35 ++++++ .../java/org/mandarin/booking/DocsUtils.java | 113 ++++++++++++++++++ .../org/mandarin/booking/IntegrationTest.java | 1 + .../booking/IntegrationTestUtils.java | 50 ++++---- .../java/org/mandarin/booking/NoRestDocs.java | 8 ++ .../java/org/mandarin/booking/TestConfig.java | 10 +- .../java/org/mandarin/booking/TestResult.java | 84 ++++++++----- .../adapter/security/JwtFilterTest.java | 2 + .../webapi/GlobalExceptionHandlerTest.java | 2 + .../booking/webapi/member/POST_specs.java | 31 ++--- 10 files changed, 254 insertions(+), 82 deletions(-) create mode 100644 src/test/java/org/mandarin/booking/DocsUtils.java create mode 100644 src/test/java/org/mandarin/booking/NoRestDocs.java diff --git a/build.gradle b/build.gradle index 7676b34..b103d64 100644 --- a/build.gradle +++ b/build.gradle @@ -2,11 +2,17 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.4' id 'io.spring.dependency-management' version '1.1.7' + id 'com.epages.restdocs-api-spec' version '0.18.2' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'org.mandarin' version = '0.0.1-SNAPSHOT' +ext { + snippetsDir = file('build/generated-snippets') +} + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -19,6 +25,7 @@ repositories { configurations { byteBuddyAgent + asciidoctorExt } dependencies { @@ -58,13 +65,41 @@ dependencies { byteBuddyAgent 'net.bytebuddy:byte-buddy-agent:1.17.6' testImplementation 'org.mockito:mockito-inline:5.2.0' + // ---- API Docs (REST Docs + Rest Assured) ---- + testImplementation 'io.rest-assured:rest-assured:5.5.6' + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.1.0' + // null safety implementation 'org.jspecify:jspecify:1.0.0' } +ext { + set('snippetsDir', file('build/generated-snippets')) +} tasks.named('test') { useJUnitPlatform() systemProperty 'spring.profiles.active', 'test' // javaagent 선부착 (Mockito self-attach 방지) jvmArgs "-javaagent:${configurations.byteBuddyAgent.singleFile}" + outputs.dir snippetsDir +} + +asciidoctor { + group = 'documentation' + description = 'Converts AsciiDoc files to HTML, including REST Docs snippets.' + dependsOn test + baseDirFollowsSourceDir() + sourceDir = file('src/docs') + sources { + include '**/*.adoc' + } + inputs.dir snippetsDir + attributes 'snippets': snippetsDir + resources { + from(snippetsDir) { into 'snippets' } + } + outputDir = layout.buildDirectory.dir("docs/asciidoc").get().asFile } + diff --git a/src/test/java/org/mandarin/booking/DocsUtils.java b/src/test/java/org/mandarin/booking/DocsUtils.java new file mode 100644 index 0000000..c65ccbb --- /dev/null +++ b/src/test/java/org/mandarin/booking/DocsUtils.java @@ -0,0 +1,113 @@ +package org.mandarin.booking; + +import static io.restassured.RestAssured.given; +import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE; +import static java.lang.StackWalker.getInstance; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.filter.Filter; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import java.lang.StackWalker.StackFrame; +import java.util.Map; +import org.springframework.core.env.Environment; +import org.springframework.restdocs.ManualRestDocumentation; +import org.springframework.restdocs.restassured.RestAssuredRestDocumentation; +import org.springframework.stereotype.Component; + +@Component +public record DocsUtils(Environment environment, + ObjectMapper objectMapper) { + private static final ManualRestDocumentation restDocumentation = new ManualRestDocumentation(); + + + private static volatile boolean started = false; + + public String execute(String method, String path, Object requestBody, Map headers) + throws Exception { + var snippet = sanitize(method, path); + boolean disableDocs = isRestDocsDisabledForCurrentCall(); + if (!disableDocs) { + ensureStarted(); + } + var spec = prepareSpec(headers, disableDocs); + if ("POST".equals(method)) { + spec.contentType(ContentType.JSON); + if (requestBody != null) { + spec.body(objectMapper.writeValueAsString(requestBody)); + } + } + var resp = ("GET".equals(method)) + ? (disableDocs ? spec.when().get(path) + : spec.filter(docFilter(snippet)).when().get(path)) + : (disableDocs ? spec.when().post(path) + : spec.filter(docFilter(snippet)).when().post(path)); + return resp.then().extract().asString(); + } + + + private String sanitize(String method, String path) { + var name = method + path; + name = name.replaceAll("^/+", ""); + name = name.replaceAll("[/{}]", "-"); + name = name.replaceAll("[^a-zA-Z0-9-_]", "-"); + return name.toLowerCase(); + } + + private boolean isRestDocsDisabledForCurrentCall() { + try { + return getInstance(RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .map(StackFrame::getDeclaringClass) + .filter(cls -> cls.getName().startsWith("org.mandarin")) + .anyMatch(cls -> cls.isAnnotationPresent(NoRestDocs.class))); + } catch (Throwable t) { + return false; + } + } + + private void ensureStarted() { + if (!started) { + synchronized (DocsUtils.class) { + if (!started) { + restDocumentation.beforeTest(DocsUtils.class, "integration-tests"); + started = true; + } + } + } + } + + private RequestSpecification withDocs(RequestSpecification spec) { + return spec.filter(RestAssuredRestDocumentation.documentationConfiguration(restDocumentation)); + } + + private Filter docFilter(String snippet) { + return document( + snippet, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ); + } + + private RequestSpecification prepareSpec(Map headers, boolean disableDocs) { + Integer port = environment.getProperty("local.server.port", Integer.class); + if (port == null) { + String p = environment.getProperty("local.server.port"); + port = (p != null) ? Integer.parseInt(p) : 0; + } + var spec = given() + .port(port) + .accept(ContentType.JSON); + if (!disableDocs) { + spec = withDocs(spec); + } + if (headers != null) { + headers.forEach(spec::header); + } + return spec; + } +} diff --git a/src/test/java/org/mandarin/booking/IntegrationTest.java b/src/test/java/org/mandarin/booking/IntegrationTest.java index dfbb45c..471ff59 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTest.java +++ b/src/test/java/org/mandarin/booking/IntegrationTest.java @@ -11,6 +11,7 @@ webEnvironment = RANDOM_PORT, classes = BookingApplication.class ) +@org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs @Import(TestConfig.class) @Retention(RetentionPolicy.RUNTIME) public @interface IntegrationTest { diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index e3be1fd..3a9699b 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -16,54 +16,49 @@ import org.mandarin.booking.domain.member.MemberAuthority; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.domain.member.TokenHolder; -import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.security.core.GrantedAuthority; import org.springframework.test.util.ReflectionTestUtils; -public class IntegrationTestUtils { - private final TestRestTemplate testRestTemplate; - private final MemberCommandRepository memberRepository; - private final TokenUtils tokenUtils; - private final SecurePasswordEncoder securePasswordEncoder; - private final ObjectMapper objectMapper; - - public IntegrationTestUtils(TestRestTemplate testRestTemplate, - MemberCommandRepository memberRepository, - TokenUtils tokenUtils, - SecurePasswordEncoder securePasswordEncoder, - ObjectMapper objectMapper) { - this.testRestTemplate = testRestTemplate; - this.memberRepository = memberRepository; - this.tokenUtils = tokenUtils; - this.securePasswordEncoder = securePasswordEncoder; - this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); +public record IntegrationTestUtils(MemberCommandRepository memberRepository, + TokenUtils tokenUtils, + SecurePasswordEncoder securePasswordEncoder, + ObjectMapper objectMapper, + DocsUtils docsUtils) { + public IntegrationTestUtils { + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } - public TestResult get(String path) { + public TestResult get(String path) { return new TestResult(path, null) - .setContext(testRestTemplate, objectMapper); + .setContext(objectMapper) + .setExecutor((p, req, headers) -> docsUtils.execute("GET", p, null, headers)); } public TestResult post(String path, T request) { return new TestResult(path, request) - .setContext(testRestTemplate, objectMapper); + .setContext(objectMapper) + .setExecutor((p, req, headers) -> docsUtils.execute("POST", p, req, headers)); } public String getValidRefreshToken() { var member = insertDummyMember(generateUserId(), generatePassword()); - return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()).refreshToken(); + return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()) + .refreshToken(); } public String getAuthToken() { var member = this.insertDummyMember(); - return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) + .accessToken(); } public String getAuthToken(Member member) { - return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) + .accessToken(); } - public TokenHolder getUserToken(String userId, String nickname, Collection authorities) { + public TokenHolder getUserToken(String userId, String nickname, + Collection authorities) { return tokenUtils.generateToken(userId, nickname, authorities); } @@ -97,9 +92,10 @@ public Member insertDummyMember() { return this.insertDummyMember(generateUserId(), generatePassword()); } - public String getAuthToken(MemberAuthority ...memberAuthority) { + public String getAuthToken(MemberAuthority... memberAuthority) { var member = this.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); - return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) + .accessToken(); } } diff --git a/src/test/java/org/mandarin/booking/NoRestDocs.java b/src/test/java/org/mandarin/booking/NoRestDocs.java new file mode 100644 index 0000000..17a56cb --- /dev/null +++ b/src/test/java/org/mandarin/booking/NoRestDocs.java @@ -0,0 +1,8 @@ +package org.mandarin.booking; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface NoRestDocs { +} diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 19c4f94..465c4f2 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -6,18 +6,16 @@ import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.annotation.Bean; @TestConfiguration public class TestConfig { @Bean - public IntegrationTestUtils integrationTestUtils(@Autowired TestRestTemplate testRestTemplate, - @Autowired MemberCommandRepository memberRepository, + public IntegrationTestUtils integrationTestUtils(@Autowired MemberCommandRepository memberRepository, @Autowired TokenUtils tokenUtils, @Autowired SecurePasswordEncoder securePasswordEncoder, - @Autowired ObjectMapper objectMapper) { - return new IntegrationTestUtils(testRestTemplate, memberRepository, tokenUtils, securePasswordEncoder, - objectMapper); + @Autowired ObjectMapper objectMapper, + @Autowired DocsUtils docsUtils) { + return new IntegrationTestUtils(memberRepository, tokenUtils, securePasswordEncoder, objectMapper, docsUtils); } } diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index f5c4218..5e07013 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -1,24 +1,20 @@ package org.mandarin.booking; import static org.assertj.core.api.Assertions.fail; -import static org.springframework.http.HttpMethod.GET; -import static org.springframework.http.HttpMethod.POST; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.Map; -import java.util.Map.Entry; import org.mandarin.booking.adapter.webapi.ApiResponse; import org.mandarin.booking.adapter.webapi.ApiStatus; import org.mandarin.booking.adapter.webapi.ErrorResponse; import org.mandarin.booking.adapter.webapi.SuccessResponse; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; public class TestResult { + private Executor executor; + private final String path; private final Object request; private final Map headers = new HashMap<>(); @@ -28,7 +24,6 @@ public TestResult(String path, Object request) { this.request = request; } - private TestRestTemplate testRestTemplate; private ObjectMapper objectMapper; public ApiResponse assertSuccess(Class responseType) { @@ -38,7 +33,7 @@ public ApiResponse assertSuccess(Class responseType) { ); if (response == null) { - throw new AssertionError("Expected SUCCESS response, but got: " + response); + throw new AssertionError("Expected SUCCESS response, but got: " + null); } else if (response.getStatus() != ApiStatus.SUCCESS) { throw new AssertionError("Expected SUCCESS response, but got Error response: " + response); } @@ -52,7 +47,7 @@ public ApiResponse assertSuccess(TypeReference typeReference) { typeReference ); if (response == null) { - throw new AssertionError("Expected SUCCESS response, but got: " + response); + throw new AssertionError("Expected SUCCESS response, but got: " + null); } else if (response.getStatus() != ApiStatus.SUCCESS) { throw new AssertionError("Expected SUCCESS response, but got Error response: " + response); } @@ -63,27 +58,47 @@ public ApiResponse assertSuccess(TypeReference typeReference) { public ErrorResponse assertFailure() { var response = readErrorResponse(); if (response == null) { - throw new AssertionError("Expected Error response, but got: " + response); + throw new AssertionError("Expected Error response, but got: " + null); }else if (response.getStatus() == ApiStatus.SUCCESS) { throw new AssertionError("Expected Error response, but got SUCCESS: " + response); } return response; } + TestResult setContext(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + return this; + } + public TestResult withHeader(String headerName, String headerValue) { headers.put(headerName, headerValue); return this; } - TestResult setContext(TestRestTemplate testRestTemplate, ObjectMapper objectMapper) { - this.testRestTemplate = testRestTemplate; - this.objectMapper = objectMapper; + TestResult setExecutor(Executor executor) { + this.executor = executor; return this; } + private boolean isErrorEnvelope(String raw) { + try { + return objectMapper.readTree(raw).has("message"); + } catch (Exception e) { + return false; + } + } + + private boolean isSuccessEnvelope(String raw) { + try { + return objectMapper.readTree(raw).has("data"); + } catch (Exception e) { + return false; + } + } + private ApiResponse readSuccessResponse(String raw, Class dataType) { try { - if(objectMapper.readTree(raw).has("message")){ + if (isErrorEnvelope(raw)) { fail("Expected SuccessResponse but got ErrorResponse: " + raw); } var wrapperType = objectMapper.getTypeFactory() @@ -108,6 +123,19 @@ private ApiResponse readSuccessResponse(String raw, Class dataType) { } } + private ErrorResponse readErrorResponse() { + var response = getResponse(); + try { + if (isSuccessEnvelope(response)) { + fail("Expected ErrorResponse but got SuccessResponse: " + response); + } + return objectMapper.readValue(response, ErrorResponse.class); + } catch (Exception e) { + fail("Failed to parse ErrorResponse: " + e.getMessage(), e); + return null; + } + } + private SuccessResponse readSuccessResponse(String raw, TypeReference typeRef) { try { var inner = objectMapper.getTypeFactory().constructType(typeRef); @@ -130,26 +158,20 @@ private SuccessResponse readSuccessResponse(String raw, TypeReference } } - private ErrorResponse readErrorResponse() { - var response = getResponse(); - try { - if(objectMapper.readTree(response).has("data")){ - fail("Expected ErrorResponse but got SuccessResponse: " + response); + private String getResponse() { + if (executor != null) { + try { + return executor.execute(path, request, headers); + } catch (Exception e) { + throw new AssertionError("Request execution failed: " + e.getMessage(), e); } - return objectMapper.readValue(response, ErrorResponse.class); - } catch (Exception e) { - fail("Failed to parse ErrorResponse: " + e.getMessage(), e); - return null; } + throw new AssertionError( + "No HTTP executor configured for TestResult. Ensure IntegrationTestUtils sets an executor."); } - private String getResponse() { - var httpHeaders = new HttpHeaders(); - for (Entry entry : headers.entrySet()) { - httpHeaders.add(entry.getKey(), entry.getValue()); - } - return (request == null) - ? testRestTemplate.exchange(path, GET, new HttpEntity<>(httpHeaders), String.class).getBody() - : testRestTemplate.exchange(path, POST, new HttpEntity<>(request, httpHeaders), String.class).getBody(); + @FunctionalInterface + public interface Executor { + String execute(String path, Object request, Map headers) throws Exception; } } diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index c7ffd57..984356e 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.NoRestDocs; import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController; import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController.TestSecurityConfig; import org.mandarin.booking.app.TokenUtils; @@ -31,6 +32,7 @@ import org.springframework.web.bind.annotation.RestController; @IntegrationTest +@NoRestDocs @Import({TestSecurityConfig.class, TestAuthController.class}) class JwtFilterTest { private static final String PONG_WITHOUT_AUTH = "pong without auth"; diff --git a/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java b/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java index c67c589..33b6069 100644 --- a/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java +++ b/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java @@ -6,9 +6,11 @@ import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.NoRestDocs; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest +@NoRestDocs class GlobalExceptionHandlerTest { @Test diff --git a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index 8cb021a..586e3ba 100644 --- a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -17,7 +17,6 @@ import org.mandarin.booking.fixture.MemberFixture.NicknameGenerator; import org.mandarin.booking.fixture.MemberFixture.PasswordGenerator; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; @IntegrationTest @DisplayName("POST /api/member") @@ -25,37 +24,35 @@ public class POST_specs { @Test void 올바른_요청하면_200_OK_상태코드를_반환한다( - @Autowired TestRestTemplate testRestTemplate + @Autowired IntegrationTestUtils testUtils ) { // Arrange var request = generateRequest(); // Act - var response = testRestTemplate.postForEntity( + var response = testUtils.post( "/api/member", - request, - Void.class - ); + request + ).assertSuccess(MemberRegisterResponse.class); // Assert - assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getData()).isNotNull(); } @Test void 올바른_회원가입_요청을_하면_데이터베이스에_회원_정보가_저장된다( - @Autowired TestRestTemplate testRestTemplate, + @Autowired IntegrationTestUtils testUtils, @Autowired MemberQueryRepository memberRepository ) { // Arrange var request = generateRequest(); // Act - testRestTemplate.postForEntity( + testUtils.post( "/api/member", - request, - Void.class - ); + request + ).assertSuccess(MemberRegisterResponse.class); // Assert var matchingMember = memberRepository.findByUserId(request.userId()).orElseThrow(); @@ -192,7 +189,7 @@ public class POST_specs { void 비밀번호가_올바르게_암호화_된다( @Autowired MemberQueryRepository memberRepository, @Autowired SecurePasswordEncoder securePasswordEncoder, - @Autowired TestRestTemplate testRestTemplate + @Autowired IntegrationTestUtils testUtils ) { // Arrange String rawPassword = PasswordGenerator.generatePassword(); @@ -204,12 +201,10 @@ public class POST_specs { ); // Act - var res = testRestTemplate.postForEntity( + testUtils.post( "/api/member", - request, - Void.class - ); - assertThat(res.getStatusCode().value()).isEqualTo(200); + request + ).assertSuccess(MemberRegisterResponse.class); // Assert var savedMember = memberRepository.findByUserId(request.userId()).orElseThrow(); From 2d79bbcf64b291487c54f5346193e459b809fcea Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 16:29:51 +0900 Subject: [PATCH 06/57] feat: add API documentation for movie schedule registration endpoint --- docs/specs/api/movie_schedule_register.md | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/specs/api/movie_schedule_register.md diff --git a/docs/specs/api/movie_schedule_register.md b/docs/specs/api/movie_schedule_register.md new file mode 100644 index 0000000..038221d --- /dev/null +++ b/docs/specs/api/movie_schedule_register.md @@ -0,0 +1,71 @@ +### 요청 + +- 메서드: `POST` +- 경로: `/api/movie/schedule` +- 헤더 + + ``` + Content-Type: application/json + Authorization: Bearer + ``` + +- 본문 예시 + + ```json + { + "showId": 1, + "hallId": 10, + "startAt": "2025-09-10T19:00:00", + "endAt": "2025-09-10T21:30:00", + "runtimeMinutes": 150 + } + ``` + +- curl 명령 예시 + + ```bash + curl -i -X POST 'http://localhost:8080/api/movie/schedule' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "showId": 1, + "hallId": 10, + "startAt": "2025-09-10T19:00:00", + "endAt": "2025-09-10T21:30:00", + "runtimeMinutes": 150 + }' + ``` + +--- + +### 응답 + +- 상태코드: `200 OK` +- 본문 예시 + + ```json + { + "scheduleId": 100, + "showId": 1, + "hallId": 10, + "startAt": "2025-09-10T19:00:00", + "endAt": "2025-09-10T21:30:00", + "runtimeMinutes": 150, + "createdAt": "2025-09-05T11:20:00" + } + ``` + +--- + +### 테스트 + +- [ ] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 +- [ ] 응답 본문에 scheduleId가 포함된다 +- [ ] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 +- [ ] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 +- [ ] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 +- [ ] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 +- [ ] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 +- [ ] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 +- [ ] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 +- [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 또는 기존 스케줄 ID를 반환한다 From a5eca1ac2bef90e53938a7f63acf2eab7234c89c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 16:31:41 +0900 Subject: [PATCH 07/57] feat: rename movie schedule registration endpoint to show schedule registration --- .../{movie_schedule_register.md => show_schedule_register.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docs/specs/api/{movie_schedule_register.md => show_schedule_register.md} (95%) diff --git a/docs/specs/api/movie_schedule_register.md b/docs/specs/api/show_schedule_register.md similarity index 95% rename from docs/specs/api/movie_schedule_register.md rename to docs/specs/api/show_schedule_register.md index 038221d..b754969 100644 --- a/docs/specs/api/movie_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -1,7 +1,7 @@ ### 요청 - 메서드: `POST` -- 경로: `/api/movie/schedule` +- 경로: `/api/show/schedule` - 헤더 ``` @@ -24,7 +24,7 @@ - curl 명령 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/movie/schedule' \ + curl -i -X POST 'http://localhost:8080/api/show/schedule' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ From 15001393d09f821297fd7ef46fee13f66122aa19 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:06:39 +0900 Subject: [PATCH 08/57] feat: implement show schedule registration functionality and related error handling --- docs/specs/api/show_schedule_register.md | 4 +- .../webapi/GlobalExceptionHandler.java | 3 +- .../adapter/webapi/ShowController.java | 7 ++ .../booking/app/ShowRegisterValidator.java | 20 ----- .../org/mandarin/booking/app/ShowService.java | 31 +++++-- .../app/persist/ShowQueryRepository.java | 10 ++- .../booking/app/persist/ShowRepository.java | 3 + .../booking/app/port/ShowRegisterer.java | 5 ++ .../booking/domain/DomainException.java | 10 +++ .../mandarin/booking/domain/show/Show.java | 62 ++++++++++++- .../domain/show/ShowCreateCommand.java | 41 --------- .../booking/domain/show/ShowException.java | 5 ++ .../booking/domain/show/ShowSchedule.java | 88 +++++++++++++++++++ .../show/ShowScheduleRegisterRequest.java | 12 +++ .../show/ShowScheduleRegisterResponse.java | 6 ++ .../booking/IntegrationTestUtils.java | 23 +++++ .../webapi/show/schedule/POST_specs.java | 41 +++++++++ 17 files changed, 296 insertions(+), 75 deletions(-) delete mode 100644 src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java delete mode 100644 src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java create mode 100644 src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java create mode 100644 src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java create mode 100644 src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java create mode 100644 src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index b754969..43bfce5 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -45,13 +45,11 @@ ```json { - "scheduleId": 100, "showId": 1, "hallId": 10, "startAt": "2025-09-10T19:00:00", "endAt": "2025-09-10T21:30:00", - "runtimeMinutes": 150, - "createdAt": "2025-09-05T11:20:00" + "runtimeMinutes": 150 } ``` diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java index 38205c9..365e46a 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -2,7 +2,6 @@ import static java.util.Objects.requireNonNull; import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; @@ -18,7 +17,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ErrorResponse handleJsonParseError(DomainException ex) { - return new ErrorResponse(INTERNAL_SERVER_ERROR, ex.getMessage()); + return new ErrorResponse(ex.getStatus(), ex.getMessage()); } @ExceptionHandler(AuthException.class) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 497fcaa..f6480ba 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -4,6 +4,8 @@ import org.mandarin.booking.app.port.ShowRegisterer; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,5 +19,10 @@ public record ShowController(ShowRegisterer showRegisterer) { public ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { return showRegisterer.register(request); } + + @PostMapping("/schedule") + public ShowScheduleRegisterResponse registerSchedule(@RequestBody @Valid ShowScheduleRegisterRequest request) { + return showRegisterer.registerSchedule(request); + } } diff --git a/src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java b/src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java deleted file mode 100644 index 3c48dd6..0000000 --- a/src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.mandarin.booking.app; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.ShowQueryRepository; -import org.mandarin.booking.domain.show.ShowException; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ShowRegisterValidator { - private final ShowQueryRepository queryRepository; - - public void checkDuplicateTitle(String title) { - if (queryRepository.existsByName(title)) { - throw new ShowException("이미 존재하는 공연 이름입니다:" + title); - } - } - - -} diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index 7df168c..2a6d20e 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -1,31 +1,50 @@ package org.mandarin.booking.app; -import static java.util.Objects.requireNonNull; - import lombok.RequiredArgsConstructor; import org.mandarin.booking.app.persist.ShowCommandRepository; +import org.mandarin.booking.app.persist.ShowQueryRepository; import org.mandarin.booking.app.port.ShowRegisterer; import org.mandarin.booking.domain.show.Show; -import org.mandarin.booking.domain.show.ShowCreateCommand; +import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +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.ShowSchedule.ShowScheduleCreateCommand; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class ShowService implements ShowRegisterer { private final ShowCommandRepository commandRepository; - private final ShowRegisterValidator validator; + private final ShowQueryRepository queryRepository; @Override public ShowRegisterResponse register(ShowRegisterRequest request) { var command = ShowCreateCommand.from(request); var show = Show.create(command); - validator.checkDuplicateTitle(show.getTitle()); + checkDuplicateTitle(show.getTitle()); + + var saved = commandRepository.insert(show); + return new ShowRegisterResponse(saved.getId()); + } + + @Override + public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { + var command = ShowScheduleCreateCommand.from(request); + var show = queryRepository.findById(command.getShowId()); + show.registerSchedule(command); var saved = commandRepository.insert(show); - return new ShowRegisterResponse(requireNonNull(saved.getId())); + return new ShowScheduleRegisterResponse(saved.getId()); + } + + public void checkDuplicateTitle(String title) { + if (queryRepository.existsByName(title)) { + throw new ShowException("이미 존재하는 공연 이름입니다:" + title); + } } } diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java index 242faac..3d97248 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java @@ -1,6 +1,10 @@ package org.mandarin.booking.app.persist; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; + import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.ShowException; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -10,8 +14,12 @@ public class ShowQueryRepository { private final ShowRepository jpaRepository; - public boolean existsByName(String title) { return jpaRepository.existsByTitle(title); } + + public Show findById(Long showId) { + return jpaRepository.findById(showId) + .orElseThrow(() -> new ShowException(NOT_FOUND, "No show with id=" + showId)); + } } diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java index 74c52a7..e152cfa 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java @@ -1,5 +1,6 @@ package org.mandarin.booking.app.persist; +import java.util.Optional; import org.mandarin.booking.domain.show.Show; import org.springframework.data.repository.Repository; @@ -7,5 +8,7 @@ public interface ShowRepository extends Repository { Show save(Show show); boolean existsByTitle(String title); + + Optional findById(Long showId); } diff --git a/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java b/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java index fbff807..eb4b66c 100644 --- a/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java +++ b/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java @@ -1,9 +1,14 @@ package org.mandarin.booking.app.port; +import jakarta.validation.Valid; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; public interface ShowRegisterer { ShowRegisterResponse register(ShowRegisterRequest request); + + ShowScheduleRegisterResponse registerSchedule(@Valid ShowScheduleRegisterRequest request); } diff --git a/src/main/java/org/mandarin/booking/domain/DomainException.java b/src/main/java/org/mandarin/booking/domain/DomainException.java index 9f54adf..1c80ae4 100644 --- a/src/main/java/org/mandarin/booking/domain/DomainException.java +++ b/src/main/java/org/mandarin/booking/domain/DomainException.java @@ -1,7 +1,17 @@ package org.mandarin.booking.domain; +import lombok.Getter; +import org.mandarin.booking.adapter.webapi.ApiStatus; + +@Getter public class DomainException extends RuntimeException { + private ApiStatus status = ApiStatus.INTERNAL_SERVER_ERROR; public DomainException(String message) { super(message); } + + public DomainException(ApiStatus status, String message) { + super(message); + this.status = status; + } } diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index df63be3..61081e1 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -1,19 +1,23 @@ package org.mandarin.booking.domain.show; +import static jakarta.persistence.FetchType.LAZY; + import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Show extends AbstractEntity { private String title; @@ -31,6 +35,21 @@ public class Show extends AbstractEntity { private LocalDate performanceEndDate; + @OneToMany(mappedBy = "show", fetch = LAZY) + private final List schedules = new ArrayList<>(); + + public Show(String title, Type type, Rating rating, String synopsis, String posterUrl, + LocalDate performanceStartDate, + LocalDate performanceEndDate) { + this.title = title; + this.type = type; + this.rating = rating; + this.synopsis = synopsis; + this.posterUrl = posterUrl; + this.performanceStartDate = performanceStartDate; + this.performanceEndDate = performanceEndDate; + } + public static Show create(ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); var endDate = command.getPerformanceEndDate(); @@ -50,6 +69,11 @@ public static Show create(ShowCreateCommand command) { ); } + public void registerSchedule(ShowScheduleCreateCommand command) { + var schedule = ShowSchedule.create(this, command); + this.schedules.add(schedule); + } + public enum Type { MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC } @@ -57,5 +81,39 @@ public enum Type { public enum Rating { ALL, AGE12, AGE15, AGE18 } + + @Getter + public static class ShowCreateCommand { + private final String title; + private final Type type; + private final Rating rating; + private final String synopsis; + private final String posterUrl; + private final LocalDate performanceStartDate; + private final LocalDate performanceEndDate; + + private ShowCreateCommand(String title, Type type, Rating rating, String synopsis, String posterUrl, + LocalDate performanceStartDate, LocalDate performanceEndDate) { + this.title = title; + this.type = type; + this.rating = rating; + this.synopsis = synopsis; + this.posterUrl = posterUrl; + this.performanceStartDate = performanceStartDate; + this.performanceEndDate = performanceEndDate; + } + + public static ShowCreateCommand from(ShowRegisterRequest request) { + return new ShowCreateCommand( + request.title(), + Type.valueOf(request.type()), + Rating.valueOf(request.rating()), + request.synopsis(), + request.posterUrl(), + request.performanceStartDate(), + request.performanceEndDate() + ); + } + } } diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java b/src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java deleted file mode 100644 index d848c88..0000000 --- a/src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.mandarin.booking.domain.show; - -import java.time.LocalDate; -import lombok.Getter; -import org.mandarin.booking.domain.show.Show.Rating; -import org.mandarin.booking.domain.show.Show.Type; - -@Getter -public class ShowCreateCommand { - private final String title; - private final Type type; - private final Rating rating; - private final String synopsis; - private final String posterUrl; - private final LocalDate performanceStartDate; - private final LocalDate performanceEndDate; - - private ShowCreateCommand(String title, Type type, Rating rating, String synopsis, String posterUrl, - LocalDate performanceStartDate, LocalDate performanceEndDate) { - this.title = title; - this.type = type; - this.rating = rating; - this.synopsis = synopsis; - this.posterUrl = posterUrl; - this.performanceStartDate = performanceStartDate; - this.performanceEndDate = performanceEndDate; - } - - public static ShowCreateCommand from(ShowRegisterRequest request) { - return new ShowCreateCommand( - request.title(), - Type.valueOf(request.type()), - Rating.valueOf(request.rating()), - request.synopsis(), - request.posterUrl(), - request.performanceStartDate(), - request.performanceEndDate() - ); - } -} - diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowException.java b/src/main/java/org/mandarin/booking/domain/show/ShowException.java index c97d86f..f999230 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowException.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowException.java @@ -1,9 +1,14 @@ package org.mandarin.booking.domain.show; +import org.mandarin.booking.adapter.webapi.ApiStatus; import org.mandarin.booking.domain.DomainException; public class ShowException extends DomainException { public ShowException(String message) { super(message); } + + public ShowException(ApiStatus status, String message) { + super(status, message); + } } diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java new file mode 100644 index 0000000..2b68c2e --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -0,0 +1,88 @@ +package org.mandarin.booking.domain.show; + +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +public class ShowSchedule extends AbstractEntity { + + @ManyToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "show_id", nullable = false) + private Show show; + + private Long hallId; + + private LocalDateTime startAt; + + private LocalDateTime endAt; + + private Integer runtimeMinutes; + + 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; + } + + public static ShowSchedule create(Show show, ShowScheduleCreateCommand command) { + return new ShowSchedule( + show, + command.hallId, + command.startAt, + command.endAt, + command.runtimeMinutes + ); + } + + + @Getter + public static class ShowScheduleCreateCommand { + private final Long showId; + private final Long hallId; + private final LocalDateTime startAt; + private final LocalDateTime endAt; + private final Integer runtimeMinutes; + + private ShowScheduleCreateCommand( + Long showId, + Long hallId, + LocalDateTime startAt, + LocalDateTime endAt, + Integer runtimeMinutes + ) { + this.showId = showId; + this.hallId = hallId; + this.startAt = startAt; + this.endAt = endAt; + this.runtimeMinutes = runtimeMinutes; + } + + public static ShowScheduleCreateCommand from(ShowScheduleRegisterRequest request) { + return new ShowScheduleCreateCommand( + request.showId(), + request.hallId(), + request.startAt(), + request.endAt(), + request.runtimeMinutes() + ); + } + } +} diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java new file mode 100644 index 0000000..922c91b --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -0,0 +1,12 @@ +package org.mandarin.booking.domain.show; + +import java.time.LocalDateTime; + +public record ShowScheduleRegisterRequest( + Long showId, + Long hallId, + LocalDateTime startAt, + LocalDateTime endAt, + Integer runtimeMinutes +) { +} diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java new file mode 100644 index 0000000..a3e93fa --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java @@ -0,0 +1,6 @@ +package org.mandarin.booking.domain.show; + +public record ShowScheduleRegisterResponse( + Long scheduleId +) { +} diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index 3a9699b..e9d9a42 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -7,19 +7,26 @@ 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 org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.app.persist.MemberCommandRepository; +import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.mandarin.booking.domain.member.MemberAuthority; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.domain.member.TokenHolder; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.springframework.security.core.GrantedAuthority; import org.springframework.test.util.ReflectionTestUtils; public record IntegrationTestUtils(MemberCommandRepository memberRepository, + ShowCommandRepository showRepository, TokenUtils tokenUtils, SecurePasswordEncoder securePasswordEncoder, ObjectMapper objectMapper, @@ -92,6 +99,22 @@ public Member insertDummyMember() { return this.insertDummyMember(generateUserId(), generatePassword()); } + public Show insertDummyShow() { + var command = ShowCreateCommand.from( + new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 5), + "MUSICAL", + "AGE12", + "synopsis", + "https://example.com/poster.jpg", + LocalDate.of(2025, 9, 10), + LocalDate.of(2025, 12, 31) + ) + ); + var show = Show.create(command); + return showRepository.insert(show); + } + public String getAuthToken(MemberAuthority... memberAuthority) { var member = this.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java new file mode 100644 index 0000000..e7f4857 --- /dev/null +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -0,0 +1,41 @@ +package org.mandarin.booking.webapi.show.schedule; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +@DisplayName("POST /api/show/schedule") +public class POST_specs { + + @Test + void 올바른_접근_토큰과_유효한_요청을_보내면_SUCCESS_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var reqeust = new ShowScheduleRegisterRequest( + show.getId(), + 10L, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + 150 + ); + + // Act + var response = testUtils.post("/api/show/schedule", reqeust) + .withHeader("Authorization", testUtils.getAuthToken()) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } +} From ad69dd703d9d5885cda384be125e582a8ce1ecd2 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:06:44 +0900 Subject: [PATCH 09/57] feat: add ShowCommandRepository to IntegrationTestUtils for testing show schedule functionality --- src/test/java/org/mandarin/booking/TestConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 465c4f2..5926c91 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.mandarin.booking.app.TokenUtils; import org.mandarin.booking.app.persist.MemberCommandRepository; +import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; @@ -12,10 +13,12 @@ public class TestConfig { @Bean public IntegrationTestUtils integrationTestUtils(@Autowired MemberCommandRepository memberRepository, + @Autowired ShowCommandRepository showRepository, @Autowired TokenUtils tokenUtils, @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper, @Autowired DocsUtils docsUtils) { - return new IntegrationTestUtils(memberRepository, tokenUtils, securePasswordEncoder, objectMapper, docsUtils); + return new IntegrationTestUtils(memberRepository, showRepository, tokenUtils, securePasswordEncoder, + objectMapper, docsUtils); } } From 637a95c9a4b79df1ba01ee383830c0d6fc3a1e53 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:07:04 +0900 Subject: [PATCH 10/57] feat: update test checklist for show schedule registration to reflect successful response --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 43bfce5..1418133 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -57,7 +57,7 @@ ### 테스트 -- [ ] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 +- [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 - [ ] 응답 본문에 scheduleId가 포함된다 - [ ] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [ ] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 From bc01f98a480d24553aa3733f368432f75624b930 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:08:09 +0900 Subject: [PATCH 11/57] feat: add test for scheduleId inclusion in show schedule registration response --- docs/specs/api/show_schedule_register.md | 2 +- .../webapi/show/schedule/POST_specs.java | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 1418133..0a6acd8 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -58,7 +58,7 @@ ### 테스트 - [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 -- [ ] 응답 본문에 scheduleId가 포함된다 +- [x] 응답 본문에 scheduleId가 포함된다 - [ ] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [ ] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 - [ ] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index e7f4857..313d9b9 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -38,4 +38,27 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); } + + @Test + void 응답_본문에_scheduleId가_포함된다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var reqeust = new ShowScheduleRegisterRequest( + show.getId(), + 10L, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + 150 + ); + + // Act + var response = testUtils.post("/api/show/schedule", reqeust) + .withHeader("Authorization", testUtils.getAuthToken()) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + assertThat(response.getData().scheduleId()).isNotNull(); + } } From f0db16dd6cd0cc01930f922f18d52dbc7f69106b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:15:15 +0900 Subject: [PATCH 12/57] feat: refactor authorization header handling in tests and add forbidden status check for unauthorized users --- docs/specs/policy/test.md | 2 +- .../adapter/security/SecurityConfig.java | 1 + .../java/org/mandarin/booking/TestResult.java | 13 +++-- .../adapter/security/JwtFilterTest.java | 10 ++-- .../booking/webapi/show/POST_specs.java | 14 ++--- .../webapi/show/schedule/POST_specs.java | 57 ++++++++++++------- 6 files changed, 61 insertions(+), 36 deletions(-) diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index 83609c8..b401862 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -72,7 +72,7 @@ // Act & Assert var response = testUtils.get("/test/with-auth") - .withHeader("Authorization", invalidToken) + .withAuthorization(invalidToken) .assertFailure(); assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 8e06ade..f4985b5 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain apiChain(HttpSecurity http, .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() ) diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index 5e07013..4bdc415 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -65,13 +65,18 @@ public ErrorResponse assertFailure() { return response; } - TestResult setContext(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + public TestResult withHeader(String headerName, String headerValue) { + headers.put(headerName, headerValue); return this; } - public TestResult withHeader(String headerName, String headerValue) { - headers.put(headerName, headerValue); + public TestResult withAuthorization(String token) { + this.withHeader("Authorization", token); + return this; + } + + TestResult setContext(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; return this; } diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java index 984356e..4f49712 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -57,7 +57,7 @@ void withAuth(@Autowired IntegrationTestUtils testUtils) { var response = testUtils.get( "/test/with-auth" ) - .withHeader("Authorization", accessToken) + .withAuthorization(accessToken) .assertSuccess(String.class); // assertThat(response.getStatus()).isEqualTo(SUCCESS); @@ -71,7 +71,7 @@ void failToAuth(@Autowired IntegrationTestUtils testUtils) { // Act & Assert var response = testUtils.get("/test/with-auth") - .withHeader("Authorization", invalidToken) + .withAuthorization(invalidToken) .assertFailure(); assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); @@ -84,7 +84,7 @@ void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { // Act var response = testUtils.get("/test/with-auth") - .withHeader("Authorization", invalidBearer) + .withAuthorization(invalidBearer) .assertFailure(); // Assert @@ -100,7 +100,7 @@ void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testU // Act var response = testUtils.get("/test/with-user-role") - .withHeader("Authorization", accessToken) + .withAuthorization(accessToken) .assertFailure(); // Assert @@ -117,7 +117,7 @@ void blankTokenWillFailToAuth( // Act var response = testUtils.get("/test/with-auth") - .withHeader("Authorization", accessToken) + .withAuthorization(accessToken) .assertFailure(); // Assert diff --git a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 0c4751c..3a0f685 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -37,7 +37,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); // Assert @@ -76,7 +76,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert @@ -104,7 +104,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert @@ -124,7 +124,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); // Assert @@ -152,7 +152,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert @@ -172,7 +172,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); var duplicateTitleRequest = validShowRegisterRequest(request.title()); @@ -182,7 +182,7 @@ public class POST_specs { "/api/show", duplicateTitleRequest ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 313d9b9..aa2f6b5 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -1,13 +1,17 @@ package org.mandarin.booking.webapi.show.schedule; import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; +import static org.mandarin.booking.domain.member.MemberAuthority.USER; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.springframework.beans.factory.annotation.Autowired; @@ -16,49 +20,64 @@ @DisplayName("POST /api/show/schedule") public class POST_specs { - @Test - void 올바른_접근_토큰과_유효한_요청을_보내면_SUCCESS_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var show = testUtils.insertDummyShow(); - var reqeust = new ShowScheduleRegisterRequest( + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { + return new ShowScheduleRegisterRequest( show.getId(), 10L, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), 150 ); + } + + @Test + void 올바른_접근_토큰과_유효한_요청을_보내면_SUCCESS_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var request = generateShowScheduleRegisterRequest(show); // Act - var response = testUtils.post("/api/show/schedule", reqeust) - .withHeader("Authorization", testUtils.getAuthToken()) + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) .assertSuccess(ShowScheduleRegisterResponse.class); // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); } - + @Test void 응답_본문에_scheduleId가_포함된다( @Autowired IntegrationTestUtils testUtils ) { // Arrange var show = testUtils.insertDummyShow(); - var reqeust = new ShowScheduleRegisterRequest( - show.getId(), - 10L, - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), - 150 - ); + var request = generateShowScheduleRegisterRequest(show); // Act - var response = testUtils.post("/api/show/schedule", reqeust) - .withHeader("Authorization", testUtils.getAuthToken()) + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) .assertSuccess(ShowScheduleRegisterResponse.class); // Assert assertThat(response.getData().scheduleId()).isNotNull(); } + + @Test + void 권한이_없는_사용자_토큰으로_요청하면_FORBIDDEN_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var request = generateShowScheduleRegisterRequest(show); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(USER)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(FORBIDDEN); + } } From 1305884c875f9db634736ce6c9ccf6a9392a94ef Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:15:19 +0900 Subject: [PATCH 13/57] feat: mark forbidden status check for unauthorized user token as complete in show schedule registration checklist --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 0a6acd8..f83d68b 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -59,7 +59,7 @@ - [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 - [x] 응답 본문에 scheduleId가 포함된다 -- [ ] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 +- [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [ ] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 - [ ] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [ ] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 From 1725481b6c5655eb9e6e2a2ee4a3817241d94748 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:27:23 +0900 Subject: [PATCH 14/57] feat: add validation for runtimeMinutes in ShowScheduleRegisterRequest and handle BAD_REQUEST response in schedule registration tests --- .../show/ShowScheduleRegisterRequest.java | 3 ++ .../webapi/show/schedule/POST_specs.java | 44 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 922c91b..5205241 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -1,5 +1,6 @@ package org.mandarin.booking.domain.show; +import jakarta.validation.constraints.Min; import java.time.LocalDateTime; public record ShowScheduleRegisterRequest( @@ -7,6 +8,8 @@ public record ShowScheduleRegisterRequest( Long hallId, LocalDateTime startAt, LocalDateTime endAt, + + @Min(value = 1, message = "상영 시간은 최소 1분 이상이어야 합니다.") Integer runtimeMinutes ) { } diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index aa2f6b5..e5238be 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -1,6 +1,7 @@ package org.mandarin.booking.webapi.show.schedule; import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; @@ -20,16 +21,6 @@ @DisplayName("POST /api/show/schedule") public class POST_specs { - private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { - return new ShowScheduleRegisterRequest( - show.getId(), - 10L, - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), - 150 - ); - } - @Test void 올바른_접근_토큰과_유효한_요청을_보내면_SUCCESS_상태코드를_반환한다( @Autowired IntegrationTestUtils testUtils @@ -46,7 +37,7 @@ private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(S // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); } - + @Test void 응답_본문에_scheduleId가_포함된다( @Autowired IntegrationTestUtils testUtils @@ -80,4 +71,35 @@ private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(S // Assert assertThat(response.getStatus()).isEqualTo(FORBIDDEN); } + + @Test + void runtimeMinutes가_0_이하일_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var request = generateShowScheduleRegisterRequest(show, 0); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { + return generateShowScheduleRegisterRequest(show, 150); + } + + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { + return new ShowScheduleRegisterRequest( + show.getId(), + 10L, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + runtimeMinutes + ); + } } From 1355219529b20417e8ae68569ccd536dcac3bfa3 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:28:04 +0900 Subject: [PATCH 15/57] feat: update checklist for show schedule registration to mark runtimeMinutes validation as complete --- docs/specs/api/show_schedule_register.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index f83d68b..cb48691 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -60,10 +60,11 @@ - [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 - [x] 응답 본문에 scheduleId가 포함된다 - [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 -- [ ] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 +- [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 - [ ] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [ ] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [ ] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 - [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 또는 기존 스케줄 ID를 반환한다 +- [ ] 하나의 상영관에 중복된 시간대의 스케줄을 생성하려 하는 경우 INTERNAL_SERVER_ERROR를 반환한다 From d2832c9a5f15a83f99c4b089ac4137950982060b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 5 Sep 2025 21:44:16 +0900 Subject: [PATCH 16/57] feat: update checklist for show schedule registration to mark runtimeMinutes validation as complete --- docs/specs/api/show_schedule_register.md | 2 +- .../org/mandarin/booking/app/ShowService.java | 2 +- .../show/ShowScheduleRegisterRequest.java | 7 +++- .../webapi/show/schedule/POST_specs.java | 34 +++++++++++++++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index cb48691..63b7b3b 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -61,7 +61,7 @@ - [x] 응답 본문에 scheduleId가 포함된다 - [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 -- [ ] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 +- [x] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [ ] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index 2a6d20e..f78cd98 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -41,7 +41,7 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest return new ShowScheduleRegisterResponse(saved.getId()); } - public void checkDuplicateTitle(String title) { + private void checkDuplicateTitle(String title) { if (queryRepository.existsByName(title)) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); } diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 5205241..99c42d5 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -1,5 +1,6 @@ package org.mandarin.booking.domain.show; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Min; import java.time.LocalDateTime; @@ -9,7 +10,11 @@ public record ShowScheduleRegisterRequest( LocalDateTime startAt, LocalDateTime endAt, - @Min(value = 1, message = "상영 시간은 최소 1분 이상이어야 합니다.") + @Min(value = 1, message = "The screening time should be at least 1 minute") Integer runtimeMinutes ) { + @AssertTrue(message = "The end time must be after the start time") + private boolean isEndAfterStart() { + return endAt.isAfter(startAt); + } } diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index e5238be..352e0f7 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -89,16 +89,44 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + @Test + void startAt이_endAt보다_늦은_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var request = generateShowScheduleRegisterRequest(show, 150, + LocalDateTime.of(2025, 9, 10, 21, 30), + LocalDateTime.of(2025, 9, 10, 19, 0) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).contains("The end time must be after the start time"); + } + + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { + return generateShowScheduleRegisterRequest(show, runtimeMinutes, LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30)); + } + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { return generateShowScheduleRegisterRequest(show, 150); } - private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes, + LocalDateTime startAt, + LocalDateTime endAt) { return new ShowScheduleRegisterRequest( show.getId(), 10L, - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), + startAt, + endAt, runtimeMinutes ); } From adffd849559f670a58ae13814d2e2a29df3eba47 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 11:40:28 +0900 Subject: [PATCH 17/57] feat: add NOT_FOUND status check for non-existent showId in show schedule registration tests --- .../webapi/show/schedule/POST_specs.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 352e0f7..c1ed274 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; import static org.mandarin.booking.domain.member.MemberAuthority.USER; @@ -110,6 +111,30 @@ public class POST_specs { assertThat(response.getData()).contains("The end time must be after the start time"); } + @Test + void 존재하지_않는_showId를_보내면_NOT_FOUND_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var request = new ShowScheduleRegisterRequest( + 9999L, + 10L, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + 150 + ); + + // 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("No show with id").contains("9999"); + } + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { return generateShowScheduleRegisterRequest(show, runtimeMinutes, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); From a7c1304f9b99e20661bd72a954310cd47be1b055 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 11:40:42 +0900 Subject: [PATCH 18/57] feat: mark NOT_FOUND status check for non-existent showId as complete in show schedule registration checklist --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 63b7b3b..e19139f 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -62,7 +62,7 @@ - [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 - [x] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 -- [ ] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 +- [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [ ] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 From 3ed8db43deaaba1f611d480bcc2eef01cae62e2c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 12:00:01 +0900 Subject: [PATCH 19/57] feat: add NOT_FOUND status checks for non-existent showId and hallId in show schedule registration --- .../org/mandarin/booking/app/ShowService.java | 15 ++++++++++ .../app/persist/ShowQueryRepository.java | 2 +- .../webapi/show/schedule/POST_specs.java | 28 +++++++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index f78cd98..5c1c87a 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -1,5 +1,7 @@ package org.mandarin.booking.app; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; + import lombok.RequiredArgsConstructor; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.app.persist.ShowQueryRepository; @@ -12,6 +14,7 @@ import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service @@ -19,6 +22,7 @@ public class ShowService implements ShowRegisterer { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; + private final ApplicationEventPublisher applicationEventPublisher; @Override public ShowRegisterResponse register(ShowRegisterRequest request) { @@ -34,6 +38,7 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var command = ShowScheduleCreateCommand.from(request); + verifyHallId(request); var show = queryRepository.findById(command.getShowId()); show.registerSchedule(command); @@ -41,10 +46,20 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest return new ShowScheduleRegisterResponse(saved.getId()); } + private void verifyHallId(ShowScheduleRegisterRequest request) { + var event = new HallVerificationEvent(request.hallId()); + + applicationEventPublisher.publishEvent(event); + if (!event.isVerified()) { + throw new ShowException(NOT_FOUND, "존재하지 않는 공연장입니다: " + request.hallId()); + } + } + private void checkDuplicateTitle(String title) { if (queryRepository.existsByName(title)) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); } } + } diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java index 3d97248..29e17bc 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java @@ -20,6 +20,6 @@ public boolean existsByName(String title) { public Show findById(Long showId) { return jpaRepository.findById(showId) - .orElseThrow(() -> new ShowException(NOT_FOUND, "No show with id=" + showId)); + .orElseThrow(() -> new ShowException(NOT_FOUND, "존재하지 않는 공연입니다.")); } } diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index c1ed274..a5971bb 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -118,7 +118,7 @@ public class POST_specs { // Arrange var show = testUtils.insertDummyShow(); var request = new ShowScheduleRegisterRequest( - 9999L, + 9999L,// 존재하지 않는 showId 10L, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), @@ -132,7 +132,31 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(NOT_FOUND); - assertThat(response.getData()).contains("No show with id").contains("9999"); + assertThat(response.getData()).contains("존재하지 않는 공연입니다."); + } + + @Test + void 존재하지_않는_hallId를_보내면_NOT_FOUND_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(); + var request = new ShowScheduleRegisterRequest( + show.getId(), + 9999L,// 존재하지 않는 hallId + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + 150 + ); + + // 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("존재하지 않는 공연장입니다"); } private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { From 5877930911c5f9c23dd957440134656a57f585a4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 12:00:05 +0900 Subject: [PATCH 20/57] feat: implement Hall entity and repository for hall verification --- .../org/mandarin/booking/app/HallService.java | 20 +++++++++++++++++++ .../booking/app/HallVerificationEvent.java | 15 ++++++++++++++ .../app/persist/HallQueryRepository.java | 16 +++++++++++++++ .../booking/app/persist/HallRepository.java | 8 ++++++++ .../mandarin/booking/domain/venue/Hall.java | 8 ++++++++ 5 files changed, 67 insertions(+) create mode 100644 src/main/java/org/mandarin/booking/app/HallService.java create mode 100644 src/main/java/org/mandarin/booking/app/HallVerificationEvent.java create mode 100644 src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java create mode 100644 src/main/java/org/mandarin/booking/app/persist/HallRepository.java create mode 100644 src/main/java/org/mandarin/booking/domain/venue/Hall.java diff --git a/src/main/java/org/mandarin/booking/app/HallService.java b/src/main/java/org/mandarin/booking/app/HallService.java new file mode 100644 index 0000000..7db41b7 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/HallService.java @@ -0,0 +1,20 @@ +package org.mandarin.booking.app; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.HallQueryRepository; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class HallService { + private final HallQueryRepository queryRepository; + + @EventListener + public void handleHallVerificationEvent(HallVerificationEvent event) { + boolean exists = queryRepository.existsById(event.getHallId()); + if (exists) { + event.verify(); + } + } +} diff --git a/src/main/java/org/mandarin/booking/app/HallVerificationEvent.java b/src/main/java/org/mandarin/booking/app/HallVerificationEvent.java new file mode 100644 index 0000000..c4ef10b --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/HallVerificationEvent.java @@ -0,0 +1,15 @@ +package org.mandarin.booking.app; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public final class HallVerificationEvent { + private final Long hallId; + private boolean verified = false; + + public void verify() { + this.verified = true; + } +} diff --git a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java new file mode 100644 index 0000000..d5f58e2 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.app.persist; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class HallQueryRepository { + private final HallRepository jpaRepository; + + public boolean existsById(Long hallId) { + return jpaRepository.existsById(hallId); + } +} diff --git a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java new file mode 100644 index 0000000..5d53171 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.persist; + +import org.mandarin.booking.domain.venue.Hall; +import org.springframework.data.repository.Repository; + +public interface HallRepository extends Repository { + boolean existsById(Long id); +} diff --git a/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/src/main/java/org/mandarin/booking/domain/venue/Hall.java new file mode 100644 index 0000000..3307bbb --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.domain.venue; + +import jakarta.persistence.Entity; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +public class Hall extends AbstractEntity { +} From d22b00f47a9828d4c7f4d4e8670cf61835beda46 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 12:00:24 +0900 Subject: [PATCH 21/57] feat: mark NOT_FOUND status check for non-existent hallId as complete in show schedule registration checklist --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index e19139f..b1795c1 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -63,7 +63,7 @@ - [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 - [x] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 -- [ ] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 +- [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [ ] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [ ] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 - [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 또는 기존 스케줄 ID를 반환한다 From eeb681f2065fca31cb5c5b1360c830d11ac02d1d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 13:57:23 +0900 Subject: [PATCH 22/57] feat: enhance show schedule registration with hall verification and schedule range checks --- .../org/mandarin/booking/app/ShowService.java | 15 +++++-- .../booking/app/persist/HallRepository.java | 2 + .../mandarin/booking/domain/show/Show.java | 6 +++ .../mandarin/booking/domain/venue/Hall.java | 10 +++++ .../webapi/show/schedule/POST_specs.java | 41 +++++++++++++++---- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index 5c1c87a..e8b9c44 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -1,7 +1,9 @@ package org.mandarin.booking.app; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.app.persist.ShowQueryRepository; @@ -37,16 +39,18 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { + checkHallId(request); + var show = queryRepository.findById(request.showId()); + checkShowScheduleRange(show, request.startAt(), request.endAt()); + var command = ShowScheduleCreateCommand.from(request); - verifyHallId(request); - var show = queryRepository.findById(command.getShowId()); show.registerSchedule(command); var saved = commandRepository.insert(show); return new ShowScheduleRegisterResponse(saved.getId()); } - private void verifyHallId(ShowScheduleRegisterRequest request) { + private void checkHallId(ShowScheduleRegisterRequest request) { var event = new HallVerificationEvent(request.hallId()); applicationEventPublisher.publishEvent(event); @@ -61,5 +65,10 @@ private void checkDuplicateTitle(String title) { } } + private static void checkShowScheduleRange(Show show, LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { + if (!show.isInSchedule(scheduleStartAt, scheduleEndAt)) { + throw new ShowException(BAD_REQUEST, "공연 기간 범위를 벗어나는 일정입니다."); + } + } } diff --git a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java index 5d53171..a8117a3 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java @@ -5,4 +5,6 @@ public interface HallRepository extends Repository { boolean existsById(Long id); + + Hall save(Hall hall); } diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index 61081e1..7d62479 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.OneToMany; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -50,6 +51,11 @@ public Show(String title, Type type, Rating rating, String synopsis, String post this.performanceEndDate = performanceEndDate; } + public boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { + return scheduleStartAt.isAfter(performanceStartDate.atStartOfDay()) + && scheduleEndAt.isBefore(performanceEndDate.atStartOfDay()); + } + public static Show create(ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); var endDate = command.getPerformanceEndDate(); diff --git a/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/src/main/java/org/mandarin/booking/domain/venue/Hall.java index 3307bbb..30cd58a 100644 --- a/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -1,8 +1,18 @@ package org.mandarin.booking.domain.venue; import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { + private Long showId; + + public static Hall create(Long showId) { + return new Hall(showId); + } } diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index a5971bb..1b1ca6e 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -8,6 +8,7 @@ import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; import static org.mandarin.booking.domain.member.MemberAuthority.USER; +import java.time.LocalDate; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,6 +17,7 @@ import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.mandarin.booking.domain.venue.Hall; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -27,7 +29,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show); // Act @@ -44,7 +46,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show); // Act @@ -61,7 +63,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show); // Act @@ -78,7 +80,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show, 0); // Act @@ -95,7 +97,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show, 150, LocalDateTime.of(2025, 9, 10, 21, 30), LocalDateTime.of(2025, 9, 10, 19, 0) @@ -116,7 +118,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId 10L, @@ -140,7 +142,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(); + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( show.getId(), 9999L,// 존재하지 않는 hallId @@ -159,6 +161,31 @@ public class POST_specs { assertThat(response.getData()).contains("존재하지 않는 공연장입니다"); } + @Test + void 공연_기간_범위를_벗어나는_startAt_또는_endAt을_보낼_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); + Hall hall = testUtils.insertDummyHall(show); + var request = new ShowScheduleRegisterRequest( + show.getId(), + hall.getId(), + LocalDateTime.of(2023, 9, 10, 19, 0), + LocalDateTime.of(2023, 9, 10, 21, 30), + 150 + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).contains("공연 기간 범위를 벗어나는 일정입니다."); + } + private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { return generateShowScheduleRegisterRequest(show, runtimeMinutes, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); From d2e820e6546fef1ef814f0e69411aff9a740317a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 13:57:28 +0900 Subject: [PATCH 23/57] feat: add hall repository integration and dummy hall creation for show registration tests --- .../mandarin/booking/IntegrationTestUtils.java | 16 ++++++++++++++-- .../java/org/mandarin/booking/TestConfig.java | 6 ++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index e9d9a42..a408758 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.UUID; import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.app.persist.HallRepository; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.domain.member.Member; @@ -22,11 +23,13 @@ import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.venue.Hall; import org.springframework.security.core.GrantedAuthority; import org.springframework.test.util.ReflectionTestUtils; public record IntegrationTestUtils(MemberCommandRepository memberRepository, ShowCommandRepository showRepository, + HallRepository hallRepository, TokenUtils tokenUtils, SecurePasswordEncoder securePasswordEncoder, ObjectMapper objectMapper, @@ -100,6 +103,10 @@ public Member insertDummyMember() { } public Show insertDummyShow() { + return this.insertDummyShow(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2)); + } + + public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { var command = ShowCreateCommand.from( new ShowRegisterRequest( UUID.randomUUID().toString().substring(0, 5), @@ -107,8 +114,8 @@ public Show insertDummyShow() { "AGE12", "synopsis", "https://example.com/poster.jpg", - LocalDate.of(2025, 9, 10), - LocalDate.of(2025, 12, 31) + performanceStartDate, + performanceEndDate ) ); var show = Show.create(command); @@ -120,5 +127,10 @@ public String getAuthToken(MemberAuthority... memberAuthority) { return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .accessToken(); } + + public Hall insertDummyHall(Show show) { + var hall = Hall.create(show.getId()); + return hallRepository.save(hall); + } } diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 5926c91..590136f 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.app.persist.HallRepository; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -14,11 +15,12 @@ public class TestConfig { @Bean public IntegrationTestUtils integrationTestUtils(@Autowired MemberCommandRepository memberRepository, @Autowired ShowCommandRepository showRepository, + @Autowired HallRepository hallRepository, @Autowired TokenUtils tokenUtils, @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper, @Autowired DocsUtils docsUtils) { - return new IntegrationTestUtils(memberRepository, showRepository, tokenUtils, securePasswordEncoder, - objectMapper, docsUtils); + return new IntegrationTestUtils(memberRepository, showRepository, hallRepository, + tokenUtils, securePasswordEncoder, objectMapper, docsUtils); } } From bb9c68bbb2f868a82df79bdb26f85c4713085762 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 6 Sep 2025 14:03:37 +0900 Subject: [PATCH 24/57] feat: mark validation for show schedule date range as complete in registration checklist --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index b1795c1..8cbf213 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -64,7 +64,7 @@ - [x] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 -- [ ] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 +- [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [ ] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 - [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 또는 기존 스케줄 ID를 반환한다 - [ ] 하나의 상영관에 중복된 시간대의 스케줄을 생성하려 하는 경우 INTERNAL_SERVER_ERROR를 반환한다 From 7e401180df0d60b3dd720bf174b5aa2b24c91082 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:25:02 +0900 Subject: [PATCH 25/57] feat: add logging for DomainException in GlobalExceptionHandler --- .../booking/adapter/webapi/GlobalExceptionHandler.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java index 365e46a..f6a9b17 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import lombok.extern.slf4j.Slf4j; import org.mandarin.booking.domain.DomainException; import org.mandarin.booking.domain.member.AuthException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -12,11 +13,13 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.NoHandlerFoundException; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ErrorResponse handleJsonParseError(DomainException ex) { + log.error("Domain Exception: {}", (Object[]) ex.getStackTrace()); return new ErrorResponse(ex.getStatus(), ex.getMessage()); } From 5ed4ba583deb0b76bb0011a73656b338e3ea2796 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:26:32 +0900 Subject: [PATCH 26/57] update hall repository integration to use HallCommandRepository and modify dummy hall creation method --- .../org/mandarin/booking/IntegrationTestUtils.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index a408758..9eeadbc 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -12,7 +12,7 @@ import java.util.List; import java.util.UUID; import org.mandarin.booking.app.TokenUtils; -import org.mandarin.booking.app.persist.HallRepository; +import org.mandarin.booking.app.persist.HallCommandRepository; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.domain.member.Member; @@ -29,7 +29,7 @@ public record IntegrationTestUtils(MemberCommandRepository memberRepository, ShowCommandRepository showRepository, - HallRepository hallRepository, + HallCommandRepository hallRepository, TokenUtils tokenUtils, SecurePasswordEncoder securePasswordEncoder, ObjectMapper objectMapper, @@ -128,9 +128,9 @@ public String getAuthToken(MemberAuthority... memberAuthority) { .accessToken(); } - public Hall insertDummyHall(Show show) { - var hall = Hall.create(show.getId()); - return hallRepository.save(hall); + public Hall insertDummyHall() { + var hall = Hall.create(); + return hallRepository.insert(hall); } } From fccc02e96b14758bdd3ba307016346ab54edb039 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:29:11 +0900 Subject: [PATCH 27/57] enhance show scheduling with hall association and conflict checks --- .../mandarin/booking/domain/show/Show.java | 28 ++++++---- .../booking/domain/show/ShowSchedule.java | 54 ++++++------------- .../mandarin/booking/domain/venue/Hall.java | 21 ++++++-- 3 files changed, 50 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index 7d62479..9908e9c 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -1,6 +1,8 @@ package org.mandarin.booking.domain.show; +import static jakarta.persistence.CascadeType.MERGE; import static jakarta.persistence.FetchType.LAZY; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -15,6 +17,7 @@ import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; +import org.mandarin.booking.domain.venue.Hall; @Entity @Getter @@ -36,12 +39,12 @@ public class Show extends AbstractEntity { private LocalDate performanceEndDate; - @OneToMany(mappedBy = "show", fetch = LAZY) + @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) private final List schedules = new ArrayList<>(); - public Show(String title, Type type, Rating rating, String synopsis, String posterUrl, - LocalDate performanceStartDate, - LocalDate performanceEndDate) { + private Show(String title, Type type, Rating rating, String synopsis, String posterUrl, + LocalDate performanceStartDate, + LocalDate performanceEndDate) { this.title = title; this.type = type; this.rating = rating; @@ -51,10 +54,6 @@ public Show(String title, Type type, Rating rating, String synopsis, String post this.performanceEndDate = performanceEndDate; } - public boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { - return scheduleStartAt.isAfter(performanceStartDate.atStartOfDay()) - && scheduleEndAt.isBefore(performanceEndDate.atStartOfDay()); - } public static Show create(ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); @@ -75,11 +74,20 @@ public static Show create(ShowCreateCommand command) { ); } - public void registerSchedule(ShowScheduleCreateCommand command) { - var schedule = ShowSchedule.create(this, command); + public void registerSchedule(Hall hall, ShowScheduleCreateCommand command) { + if (!isInSchedule(command.startAt(), command.endAt())) { + throw new ShowException(BAD_REQUEST, "공연 기간 범위를 벗어나는 일정입니다."); + } + + var schedule = ShowSchedule.create(this, hall, command); this.schedules.add(schedule); } + private boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { + return scheduleStartAt.isAfter(performanceStartDate.atStartOfDay()) + && scheduleEndAt.isBefore(performanceEndDate.atStartOfDay()); + } + public enum Type { MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC } diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java index 2b68c2e..38fb590 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -6,10 +6,12 @@ import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import java.time.Duration; import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.venue.Hall; @Entity @Getter @@ -20,7 +22,9 @@ public class ShowSchedule extends AbstractEntity { @JoinColumn(name = "show_id", nullable = false) private Show show; - private Long hallId; + @ManyToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "hall_id", nullable = false) + private Hall hall; private LocalDateTime startAt; @@ -30,59 +34,33 @@ public class ShowSchedule extends AbstractEntity { private ShowSchedule( Show show, - Long hallId, + Hall hall, LocalDateTime startAt, LocalDateTime endAt, Integer runtimeMinutes ) { this.show = show; - this.hallId = hallId; + this.hall = hall; this.startAt = startAt; this.endAt = endAt; this.runtimeMinutes = runtimeMinutes; } - public static ShowSchedule create(Show show, ShowScheduleCreateCommand command) { + public boolean isConflict(LocalDateTime startAt, LocalDateTime endAt) { + return startAt.isBefore(this.endAt) + && endAt.isAfter(this.startAt); + } + + static ShowSchedule create(Show show, Hall hall, ShowScheduleCreateCommand command) { return new ShowSchedule( show, - command.hallId, + hall, command.startAt, command.endAt, - command.runtimeMinutes + (int) Duration.between(command.startAt, command.endAt).toMinutes() ); } - - @Getter - public static class ShowScheduleCreateCommand { - private final Long showId; - private final Long hallId; - private final LocalDateTime startAt; - private final LocalDateTime endAt; - private final Integer runtimeMinutes; - - private ShowScheduleCreateCommand( - Long showId, - Long hallId, - LocalDateTime startAt, - LocalDateTime endAt, - Integer runtimeMinutes - ) { - this.showId = showId; - this.hallId = hallId; - this.startAt = startAt; - this.endAt = endAt; - this.runtimeMinutes = runtimeMinutes; - } - - public static ShowScheduleCreateCommand from(ShowScheduleRegisterRequest request) { - return new ShowScheduleCreateCommand( - request.showId(), - request.hallId(), - request.startAt(), - request.endAt(), - request.runtimeMinutes() - ); - } + public record ShowScheduleCreateCommand(Long showId, LocalDateTime startAt, LocalDateTime endAt) { } } diff --git a/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/src/main/java/org/mandarin/booking/domain/venue/Hall.java index 30cd58a..d05e77b 100644 --- a/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -1,18 +1,29 @@ package org.mandarin.booking.domain.venue; import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; -import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.ShowSchedule; @Entity +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { - private Long showId; + @OneToMany(mappedBy = "hall") + private List showSchedules = new ArrayList<>(); - public static Hall create(Long showId) { - return new Hall(showId); + public boolean canScheduleOn(LocalDateTime startAt, LocalDateTime endAt) { + return showSchedules.stream() + .noneMatch(schedule -> schedule.isConflict(startAt, endAt)); + } + + public static Hall create() { + return new Hall(); } } From 8a26366a35efb0f58e78bf9a429083b3406f713d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:30:10 +0900 Subject: [PATCH 28/57] integrate hall verification into show schedule registration --- .../org/mandarin/booking/app/ShowService.java | 29 ++++--------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index e8b9c44..4a9af2a 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -1,10 +1,7 @@ package org.mandarin.booking.app; -import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; - -import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.HallQueryRepository; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.app.persist.ShowQueryRepository; import org.mandarin.booking.app.port.ShowRegisterer; @@ -24,6 +21,7 @@ public class ShowService implements ShowRegisterer { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; + private final HallQueryRepository hallQueryRepository; private final ApplicationEventPublisher applicationEventPublisher; @Override @@ -39,36 +37,19 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { - checkHallId(request); var show = queryRepository.findById(request.showId()); - checkShowScheduleRange(show, request.startAt(), request.endAt()); - - var command = ShowScheduleCreateCommand.from(request); + var hall = hallQueryRepository.getScreenableHall(request); + var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); - show.registerSchedule(command); + show.registerSchedule(hall, command); var saved = commandRepository.insert(show); return new ShowScheduleRegisterResponse(saved.getId()); } - private void checkHallId(ShowScheduleRegisterRequest request) { - var event = new HallVerificationEvent(request.hallId()); - - applicationEventPublisher.publishEvent(event); - if (!event.isVerified()) { - throw new ShowException(NOT_FOUND, "존재하지 않는 공연장입니다: " + request.hallId()); - } - } - private void checkDuplicateTitle(String title) { if (queryRepository.existsByName(title)) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); } } - - private static void checkShowScheduleRange(Show show, LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { - if (!show.isInSchedule(scheduleStartAt, scheduleEndAt)) { - throw new ShowException(BAD_REQUEST, "공연 기간 범위를 벗어나는 일정입니다."); - } - } } From f375c21b1c2bbd1948955f06fbbd2d45b92f2e76 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:30:23 +0900 Subject: [PATCH 29/57] implement hall retrieval and scheduling validation in HallQueryRepository --- .../booking/app/persist/HallQueryRepository.java | 13 +++++++++++-- .../booking/app/persist/HallRepository.java | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java index d5f58e2..9178875 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java @@ -1,6 +1,10 @@ package org.mandarin.booking.app.persist; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.show.ShowException; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.venue.Hall; +import org.mandarin.booking.domain.venue.HallException; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -10,7 +14,12 @@ public class HallQueryRepository { private final HallRepository jpaRepository; - public boolean existsById(Long hallId) { - return jpaRepository.existsById(hallId); + public Hall getScreenableHall(ShowScheduleRegisterRequest request) { + var hall = jpaRepository.findById(request.hallId()) + .orElseThrow(() -> new HallException("해당 공연장을 찾을 수 없습니다.")); + if (!hall.canScheduleOn(request.startAt(), request.endAt())) { + throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); + } + return hall; } } diff --git a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java index a8117a3..fccfaed 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java @@ -1,5 +1,6 @@ package org.mandarin.booking.app.persist; +import java.util.Optional; import org.mandarin.booking.domain.venue.Hall; import org.springframework.data.repository.Repository; @@ -7,4 +8,6 @@ public interface HallRepository extends Repository { boolean existsById(Long id); Hall save(Hall hall); + + Optional findById(Long hallId); } From 5061c57e112643e9c24426c47de59288f8cc9bab Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:30:49 +0900 Subject: [PATCH 30/57] remove hall event handler --- .../org/mandarin/booking/app/HallService.java | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/main/java/org/mandarin/booking/app/HallService.java diff --git a/src/main/java/org/mandarin/booking/app/HallService.java b/src/main/java/org/mandarin/booking/app/HallService.java deleted file mode 100644 index 7db41b7..0000000 --- a/src/main/java/org/mandarin/booking/app/HallService.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.mandarin.booking.app; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.HallQueryRepository; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class HallService { - private final HallQueryRepository queryRepository; - - @EventListener - public void handleHallVerificationEvent(HallVerificationEvent event) { - boolean exists = queryRepository.existsById(event.getHallId()); - if (exists) { - event.verify(); - } - } -} From e291acfbe6ae2fa4ad585a39c78f4362aeb059af Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:31:11 +0900 Subject: [PATCH 31/57] add HallCommandRepository for hall insertion functionality --- .../app/persist/HallCommandRepository.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java diff --git a/src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java new file mode 100644 index 0000000..2389f62 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java @@ -0,0 +1,18 @@ +package org.mandarin.booking.app.persist; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.venue.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); + } +} + From 306d57d114df61f1a99e54664bc9181294370117 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:31:58 +0900 Subject: [PATCH 32/57] add HallException for handling venue-related errors --- .../org/mandarin/booking/domain/venue/HallException.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/org/mandarin/booking/domain/venue/HallException.java diff --git a/src/main/java/org/mandarin/booking/domain/venue/HallException.java b/src/main/java/org/mandarin/booking/domain/venue/HallException.java new file mode 100644 index 0000000..bf02188 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/venue/HallException.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.domain.venue; + +import org.mandarin.booking.domain.DomainException; + +public class HallException extends DomainException { + public HallException(String message) { + super(message); + } +} From 0c5ef6d767019ddad847fcca5eb334aca5e7efb6 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:32:07 +0900 Subject: [PATCH 33/57] refactor IntegrationTest annotation for cleaner code --- src/test/java/org/mandarin/booking/IntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/mandarin/booking/IntegrationTest.java b/src/test/java/org/mandarin/booking/IntegrationTest.java index 471ff59..0343906 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTest.java +++ b/src/test/java/org/mandarin/booking/IntegrationTest.java @@ -4,6 +4,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; @@ -11,7 +12,7 @@ webEnvironment = RANDOM_PORT, classes = BookingApplication.class ) -@org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +@AutoConfigureRestDocs @Import(TestConfig.class) @Retention(RetentionPolicy.RUNTIME) public @interface IntegrationTest { From ed46a80b1c9f9e3259409b4aeb5ef4cda4aba609 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:32:17 +0900 Subject: [PATCH 34/57] update TestConfig to use HallCommandRepository for hall operations --- src/test/java/org/mandarin/booking/TestConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 590136f..2cf1f84 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.mandarin.booking.app.TokenUtils; -import org.mandarin.booking.app.persist.HallRepository; +import org.mandarin.booking.app.persist.HallCommandRepository; import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -15,7 +15,7 @@ public class TestConfig { @Bean public IntegrationTestUtils integrationTestUtils(@Autowired MemberCommandRepository memberRepository, @Autowired ShowCommandRepository showRepository, - @Autowired HallRepository hallRepository, + @Autowired HallCommandRepository hallRepository, @Autowired TokenUtils tokenUtils, @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper, From 879a9bb060a90a79993e81f311e1d066b64aff83 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:32:26 +0900 Subject: [PATCH 35/57] add test for overlapping show schedule registration to return INTERNAL_SERVER_ERROR --- .../webapi/show/schedule/POST_specs.java | 74 ++++++++++++++++--- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 1b1ca6e..78c883c 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; +import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; @@ -10,6 +11,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; @@ -17,7 +19,6 @@ import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; -import org.mandarin.booking.domain.venue.Hall; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -100,7 +101,7 @@ public class POST_specs { var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show, 150, LocalDateTime.of(2025, 9, 10, 21, 30), - LocalDateTime.of(2025, 9, 10, 19, 0) + LocalDateTime.of(2025, 9, 10, 19, 0), 10L ); // Act @@ -167,7 +168,7 @@ public class POST_specs { ) { // Arrange var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); - Hall hall = testUtils.insertDummyHall(show); + var hall = testUtils.insertDummyHall(); var request = new ShowScheduleRegisterRequest( show.getId(), hall.getId(), @@ -186,21 +187,74 @@ public class POST_specs { assertThat(response.getData()).contains("공연 기간 범위를 벗어나는 일정입니다."); } - private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { + @Test + void 동일한_hallId와_시간이_겹치는_회차를_등록하려_하면_INTERNAL_SERVER_ERROR를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var hall = testUtils.insertDummyHall(); + + var show = testUtils.insertDummyShow( + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(10) + ); + var request = generateShowScheduleRegisterRequest(show, hall.getId(), + LocalDateTime.now(), + LocalDateTime.now().plusHours(2) + ); + + var anotherShow = testUtils.insertDummyShow( + LocalDate.now().minusDays(2), + LocalDate.now().plusDays(30) + ); + var nextRequest = generateShowScheduleRegisterRequest(anotherShow, hall.getId(), + LocalDateTime.now().plusHours(1), + LocalDateTime.now().plusHours(3) + ); + + testUtils.post( + "/api/show/schedule", + request + ) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Act + var response = testUtils.post( + "/api/show/schedule", + nextRequest + ) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(INTERNAL_SERVER_ERROR); + assertThat(response.getData()).contains("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); + } + + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { return generateShowScheduleRegisterRequest(show, runtimeMinutes, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); + LocalDateTime.of(2025, 9, 10, 21, 30), 10L); } - private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { return generateShowScheduleRegisterRequest(show, 150); } - private static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes, - LocalDateTime startAt, - LocalDateTime endAt) { + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, + long hallId, + LocalDateTime startAt, + LocalDateTime endAt) { + return generateShowScheduleRegisterRequest(show, + (int) ChronoUnit.MINUTES.between(startAt, endAt), startAt, endAt, hallId); + } + + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes, + LocalDateTime startAt, + LocalDateTime endAt, long hallId) { return new ShowScheduleRegisterRequest( show.getId(), - 10L, + hallId, startAt, endAt, runtimeMinutes From 85809e3855c9a659437bd4477838b0ba71a05541 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:32:38 +0900 Subject: [PATCH 36/57] update show schedule registration tests for overlapping hallId scenarios --- docs/specs/api/show_schedule_register.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 8cbf213..cab983c 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -65,6 +65,6 @@ - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 -- [ ] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 -- [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 또는 기존 스케줄 ID를 반환한다 +- [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 +- [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 반환한다 - [ ] 하나의 상영관에 중복된 시간대의 스케줄을 생성하려 하는 경우 INTERNAL_SERVER_ERROR를 반환한다 From 9c2573ee2efab2ef42f82411cd15ac5c88301f45 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 17:42:35 +0900 Subject: [PATCH 37/57] refactor exception handling to use string status in DomainException and related classes --- .../adapter/webapi/GlobalExceptionHandler.java | 3 ++- .../booking/app/persist/HallQueryRepository.java | 2 +- .../booking/app/persist/ShowQueryRepository.java | 3 +-- .../mandarin/booking/domain/DomainException.java | 5 ++--- .../org/mandarin/booking/domain/show/Show.java | 3 +-- .../booking/domain/show/ShowException.java | 3 +-- .../booking/domain/venue/HallException.java | 4 ++-- .../org/mandarin/booking/IntegrationTestUtils.java | 4 ---- .../booking/webapi/show/schedule/POST_specs.java | 14 +++++++++++--- 9 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java index f6a9b17..0892a64 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -20,7 +20,8 @@ public class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ErrorResponse handleJsonParseError(DomainException ex) { log.error("Domain Exception: {}", (Object[]) ex.getStackTrace()); - return new ErrorResponse(ex.getStatus(), ex.getMessage()); + var status = ex.getStatus(); + return new ErrorResponse(ApiStatus.valueOf(status), ex.getMessage()); } @ExceptionHandler(AuthException.class) diff --git a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java index 9178875..b2943e8 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java @@ -16,7 +16,7 @@ public class HallQueryRepository { public Hall getScreenableHall(ShowScheduleRegisterRequest request) { var hall = jpaRepository.findById(request.hallId()) - .orElseThrow(() -> new HallException("해당 공연장을 찾을 수 없습니다.")); + .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); if (!hall.canScheduleOn(request.startAt(), request.endAt())) { throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java index 29e17bc..cb2dc31 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java @@ -1,6 +1,5 @@ package org.mandarin.booking.app.persist; -import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.show.Show; @@ -20,6 +19,6 @@ public boolean existsByName(String title) { public Show findById(Long showId) { return jpaRepository.findById(showId) - .orElseThrow(() -> new ShowException(NOT_FOUND, "존재하지 않는 공연입니다.")); + .orElseThrow(() -> new ShowException("NOT_FOUND", "존재하지 않는 공연입니다.")); } } diff --git a/src/main/java/org/mandarin/booking/domain/DomainException.java b/src/main/java/org/mandarin/booking/domain/DomainException.java index 1c80ae4..41d7a26 100644 --- a/src/main/java/org/mandarin/booking/domain/DomainException.java +++ b/src/main/java/org/mandarin/booking/domain/DomainException.java @@ -1,16 +1,15 @@ package org.mandarin.booking.domain; import lombok.Getter; -import org.mandarin.booking.adapter.webapi.ApiStatus; @Getter public class DomainException extends RuntimeException { - private ApiStatus status = ApiStatus.INTERNAL_SERVER_ERROR; + private String status = "INTERNAL_SERVER_ERROR"; public DomainException(String message) { super(message); } - public DomainException(ApiStatus status, String message) { + public DomainException(String status, String message) { super(message); this.status = status; } diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index 9908e9c..49b28c3 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -2,7 +2,6 @@ import static jakarta.persistence.CascadeType.MERGE; import static jakarta.persistence.FetchType.LAZY; -import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -76,7 +75,7 @@ public static Show create(ShowCreateCommand command) { public void registerSchedule(Hall hall, ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { - throw new ShowException(BAD_REQUEST, "공연 기간 범위를 벗어나는 일정입니다."); + throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); } var schedule = ShowSchedule.create(this, hall, command); diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowException.java b/src/main/java/org/mandarin/booking/domain/show/ShowException.java index f999230..a562fae 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowException.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowException.java @@ -1,6 +1,5 @@ package org.mandarin.booking.domain.show; -import org.mandarin.booking.adapter.webapi.ApiStatus; import org.mandarin.booking.domain.DomainException; public class ShowException extends DomainException { @@ -8,7 +7,7 @@ public ShowException(String message) { super(message); } - public ShowException(ApiStatus status, String message) { + public ShowException(String status, String message) { super(status, message); } } diff --git a/src/main/java/org/mandarin/booking/domain/venue/HallException.java b/src/main/java/org/mandarin/booking/domain/venue/HallException.java index bf02188..e65ebcb 100644 --- a/src/main/java/org/mandarin/booking/domain/venue/HallException.java +++ b/src/main/java/org/mandarin/booking/domain/venue/HallException.java @@ -3,7 +3,7 @@ import org.mandarin.booking.domain.DomainException; public class HallException extends DomainException { - public HallException(String message) { - super(message); + public HallException(String status, String message) { + super(status, message); } } diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index 9eeadbc..fd844b0 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -102,10 +102,6 @@ public Member insertDummyMember() { return this.insertDummyMember(generateUserId(), generatePassword()); } - public Show insertDummyShow() { - return this.insertDummyShow(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2)); - } - public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { var command = ShowCreateCommand.from( new ShowRegisterRequest( diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 78c883c..8ffac73 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -31,7 +31,11 @@ public class POST_specs { ) { // Arrange var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = generateShowScheduleRegisterRequest(show); + var hall = testUtils.insertDummyHall(); + var request = generateShowScheduleRegisterRequest( + show, hall.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30)); // Act var response = testUtils.post("/api/show/schedule", request) @@ -48,7 +52,11 @@ public class POST_specs { ) { // Arrange var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = generateShowScheduleRegisterRequest(show); + var hall = testUtils.insertDummyHall(); + var request = generateShowScheduleRegisterRequest( + show, hall.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30)); // Act var response = testUtils.post("/api/show/schedule", request) @@ -159,7 +167,7 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(NOT_FOUND); - assertThat(response.getData()).contains("존재하지 않는 공연장입니다"); + assertThat(response.getData()).contains("해당 공연장을 찾을 수 없습니다."); } @Test From 68899d841af673b2dec7b2fa2ee1e9c0525a22af Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 19:48:13 +0900 Subject: [PATCH 38/57] add proxy tests for AbstractEntity and implement ShowSchedule conflict checks --- .../booking/app/HallVerificationEvent.java | 15 --- .../booking/domain/AbstractEntityTest.java | 96 +++++++++++++++++++ .../booking/domain/show/ShowScheduleTest.java | 58 +++++++++++ 3 files changed, 154 insertions(+), 15 deletions(-) delete mode 100644 src/main/java/org/mandarin/booking/app/HallVerificationEvent.java create mode 100644 src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java diff --git a/src/main/java/org/mandarin/booking/app/HallVerificationEvent.java b/src/main/java/org/mandarin/booking/app/HallVerificationEvent.java deleted file mode 100644 index c4ef10b..0000000 --- a/src/main/java/org/mandarin/booking/app/HallVerificationEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.mandarin.booking.app; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public final class HallVerificationEvent { - private final Long hallId; - private boolean verified = false; - - public void verify() { - this.verified = true; - } -} diff --git a/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java b/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java index ab9d09e..55ede8a 100644 --- a/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java +++ b/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java @@ -14,6 +14,69 @@ class AbstractEntityTest { static class Member extends AbstractEntity { } static class Product extends AbstractEntity { } + @Nested + @DisplayName("proxy branches") + class ProxySpec { + @Test + @DisplayName("id가 같으면 동일 프록시를 의미한다") + void equals_real_vs_proxy_same_class_same_id_true() { + var real = withId(new Member(), 100L); + var proxy = withId(new ProxyMember(), 100L); + assertThat(real).isEqualTo(proxy); + assertThat(proxy).isEqualTo(real); + } + + @Test + @DisplayName("동일 프록시라도 클래스가 다르면 다른값을 반환한다") + void equals_proxy_vs_proxy_different_class_false() { + var a = withId(new ProxyMember(), 1L); + var b = withId(new ProxyProduct(), 1L); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("프록시는 동일한 해시값을 가진다") + void hashCode_proxy_branch() { + var real = withId(new Member(), 1L); + var proxy = withId(new ProxyMember(), 2L); + assertThat(proxy.hashCode()).isEqualTo(real.getClass().hashCode()); + } + } + + // ---- HibernateProxy test doubles to cover proxy branches ---- + static class ProxyMember extends Member implements org.hibernate.proxy.HibernateProxy { + @Override + public org.hibernate.proxy.LazyInitializer getHibernateLazyInitializer() { + Class persistentClass = Member.class; + return (org.hibernate.proxy.LazyInitializer) java.lang.reflect.Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{org.hibernate.proxy.LazyInitializer.class}, + (proxy, method, args) -> { + if (method.getName().equals("getPersistentClass")) { + return persistentClass; + } + if (method.getReturnType().isPrimitive()) { + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + } + return null; + } + ); + } + + @Override + public Object writeReplace() { + return this; + } + } + private static T withId(T entity, Long id) { try { Field f = AbstractEntity.class.getDeclaredField("id"); @@ -125,4 +188,37 @@ void hashCode_Collection() { assertThat(set).hasSize(3); } } + + static class ProxyProduct extends Product implements org.hibernate.proxy.HibernateProxy { + @Override + public org.hibernate.proxy.LazyInitializer getHibernateLazyInitializer() { + Class persistentClass = Product.class; + return (org.hibernate.proxy.LazyInitializer) java.lang.reflect.Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{org.hibernate.proxy.LazyInitializer.class}, + (proxy, method, args) -> { + if (method.getName().equals("getPersistentClass")) { + return persistentClass; + } + if (method.getReturnType().isPrimitive()) { + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + } + return null; + } + ); + } + + @Override + public Object writeReplace() { + return this; + } + } } diff --git a/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java b/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java new file mode 100644 index 0000000..b4e58f2 --- /dev/null +++ b/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java @@ -0,0 +1,58 @@ +package org.mandarin.booking.domain.show; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ShowScheduleTest { + + static Stream cases() { + var bStart = LocalDateTime.of(2025, 9, 10, 19, 0); + var bEnd = LocalDateTime.of(2025, 9, 10, 21, 0); + return Stream.of( + // True cases + Arguments.of("exact same", bStart, bEnd, bStart, bEnd, true), + Arguments.of("contained within", bStart, bEnd, bStart.plusMinutes(30), bEnd.minusMinutes(30), true), + Arguments.of("containing (wraps around)", bStart, bEnd, bStart.minusMinutes(15), bEnd.plusMinutes(15), + true), + Arguments.of("overlap at start (ends inside)", bStart, bEnd, bStart.minusMinutes(30), + bStart.plusMinutes(30), true), + Arguments.of("overlap at end (starts inside)", bStart, bEnd, bEnd.minusMinutes(30), + bEnd.plusMinutes(30), true), + + // False cases (boundaries and no overlap) + Arguments.of("touching before (end equals base.start)", bStart, bEnd, bStart.minusHours(2), bStart, + false), + Arguments.of("touching after (start equals base.end)", bStart, bEnd, bEnd, bEnd.plusHours(2), false), + Arguments.of("completely before", bStart, bEnd, bStart.minusHours(3), bStart.minusHours(1), false), + Arguments.of("completely after", bStart, bEnd, bEnd.plusHours(1), bEnd.plusHours(3), false) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + void isConflict( + String name, + LocalDateTime baseStart, + LocalDateTime baseEnd, + LocalDateTime targetStart, + LocalDateTime targetEnd, + boolean expected + ) { + // Arrange + var base = ShowSchedule.create(null, null, + new ShowSchedule.ShowScheduleCreateCommand(1L, baseStart, baseEnd)); + + // Act + boolean actual = base.isConflict(targetStart, targetEnd); + + // Assert + assertThat(actual) + .as("case '%s' should be %s", name, expected) + .isEqualTo(expected); + } +} From 74a0234823d37524c1569af73ae373a3ea2d5568 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 7 Sep 2025 19:51:29 +0900 Subject: [PATCH 39/57] refactor ShowScheduleTest to improve overlap test cases and streamline false case assertions --- .../java/org/mandarin/booking/domain/show/ShowScheduleTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java b/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java index b4e58f2..077ffe9 100644 --- a/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java +++ b/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java @@ -24,7 +24,7 @@ static Stream cases() { Arguments.of("overlap at end (starts inside)", bStart, bEnd, bEnd.minusMinutes(30), bEnd.plusMinutes(30), true), - // False cases (boundaries and no overlap) + // False cases Arguments.of("touching before (end equals base.start)", bStart, bEnd, bStart.minusHours(2), bStart, false), Arguments.of("touching after (start equals base.end)", bStart, bEnd, bEnd, bEnd.plusHours(2), false), From 7e8cef19400d13b4e4b85be6f3f16befcdcc4ed5 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 00:06:19 +0900 Subject: [PATCH 40/57] add validation for runtimeMinutes to ensure it matches the difference between startAt and endAt --- docs/specs/api/show_schedule_register.md | 3 +-- .../show/ShowScheduleRegisterRequest.java | 7 ++++++ .../webapi/show/schedule/POST_specs.java | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index cab983c..c4ae6e1 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -61,10 +61,9 @@ - [x] 응답 본문에 scheduleId가 포함된다 - [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 +- [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를 반환한다 -- [ ] 동일한 요청을 여러 번 전송하면 중복 스케줄이 생성되지 않고 INTERNAL_SERVER_ERROR 반환한다 -- [ ] 하나의 상영관에 중복된 시간대의 스케줄을 생성하려 하는 경우 INTERNAL_SERVER_ERROR를 반환한다 diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 99c42d5..96e7df4 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Min; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; public record ShowScheduleRegisterRequest( Long showId, @@ -17,4 +18,10 @@ public record ShowScheduleRegisterRequest( private boolean isEndAfterStart() { return endAt.isAfter(startAt); } + + @AssertTrue(message = "The runtime must match the difference between start and end times") + private boolean isRuntimeValid() { + long between = ChronoUnit.MINUTES.between(startAt, endAt); + return between == runtimeMinutes; + } } diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 8ffac73..d913d68 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -101,6 +101,29 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + @Test + void runtimeMinutes은_startAt과_endAt의_차이만큼이_아니면_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hall = testUtils.insertDummyHall(); + var request = generateShowScheduleRegisterRequest(show, 100, +// runtimeMinutes가 100분으로 startAt, endAt의 차이인 150분과 다름 + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + hall.getId()); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).contains("The runtime must match the difference between start and end times"); + } + @Test void startAt이_endAt보다_늦은_경우_BAD_REQUEST를_반환한다( @Autowired IntegrationTestUtils testUtils From ae663ebdf7cae110e1cf3f4fc3f6da8ca0acfa21 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 00:08:35 +0900 Subject: [PATCH 41/57] update show schedule registration request to clarify runtimeMinutes comment --- .../org/mandarin/booking/webapi/show/schedule/POST_specs.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index d913d68..f1c6d3d 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -108,8 +108,8 @@ public class POST_specs { // Arrange var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var hall = testUtils.insertDummyHall(); - var request = generateShowScheduleRegisterRequest(show, 100, -// runtimeMinutes가 100분으로 startAt, endAt의 차이인 150분과 다름 + var request = generateShowScheduleRegisterRequest(show, + 100,// runtimeMinutes가 100분으로 startAt, endAt의 차이인 150분과 다름 LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), hall.getId()); From d3510efac8146f33ed4852ba94f89dc11df2692e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 09:34:15 +0900 Subject: [PATCH 42/57] refactor hall query and show schedule registration for improved validation --- src/main/java/org/mandarin/booking/app/ShowService.java | 2 +- .../booking/app/persist/HallQueryRepository.java | 9 ++------- src/main/java/org/mandarin/booking/domain/show/Show.java | 3 +++ .../booking/domain/show/ShowScheduleRegisterRequest.java | 3 +++ 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index 4a9af2a..03650e5 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -38,7 +38,7 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); - var hall = hallQueryRepository.getScreenableHall(request); + var hall = hallQueryRepository.getHall(request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); show.registerSchedule(hall, command); diff --git a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java index b2943e8..59a6e29 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java @@ -1,7 +1,6 @@ package org.mandarin.booking.app.persist; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.show.ShowException; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.venue.Hall; import org.mandarin.booking.domain.venue.HallException; @@ -14,12 +13,8 @@ public class HallQueryRepository { private final HallRepository jpaRepository; - public Hall getScreenableHall(ShowScheduleRegisterRequest request) { - var hall = jpaRepository.findById(request.hallId()) + public Hall getHall(ShowScheduleRegisterRequest request) { + return jpaRepository.findById(request.hallId()) .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); - if (!hall.canScheduleOn(request.startAt(), request.endAt())) { - throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); - } - return hall; } } diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index 49b28c3..5979d71 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -77,6 +77,9 @@ public void registerSchedule(Hall hall, ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); } + if (!hall.canScheduleOn(command.startAt(), command.endAt())) { + throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); + } var schedule = ShowSchedule.create(this, hall, command); this.schedules.add(schedule); diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 96e7df4..fcd0963 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Min; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import org.springframework.core.annotation.Order; public record ShowScheduleRegisterRequest( Long showId, @@ -14,11 +15,13 @@ public record ShowScheduleRegisterRequest( @Min(value = 1, message = "The screening time should be at least 1 minute") Integer runtimeMinutes ) { + @Order(0) @AssertTrue(message = "The end time must be after the start time") private boolean isEndAfterStart() { return endAt.isAfter(startAt); } + @Order(1) @AssertTrue(message = "The runtime must match the difference between start and end times") private boolean isRuntimeValid() { long between = ChronoUnit.MINUTES.between(startAt, endAt); From f2faf96a52dbd674efac5fde18e3e18bc19b66a9 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 10:38:57 +0900 Subject: [PATCH 43/57] implement hall existence check and refactor show schedule registration logic --- build.gradle | 11 ++- .../booking/app/HallExistCheckEvent.java | 17 +++++ .../org/mandarin/booking/app/HallService.java | 19 ++++++ .../mandarin/booking/app/QuerydslConfig.java | 18 +++++ .../org/mandarin/booking/app/ShowService.java | 25 +++++-- .../app/UnscheduledHallCheckEvent.java | 9 +++ .../app/persist/HallQueryRepository.java | 10 +-- .../booking/app/persist/HallRepository.java | 5 +- .../app/persist/ShowQueryRepository.java | 16 +++++ .../booking/domain/AbstractEntity.java | 4 +- .../mandarin/booking/domain/show/Show.java | 8 +-- .../booking/domain/show/ShowSchedule.java | 13 ++-- .../show/ShowScheduleRegisterRequest.java | 16 +---- .../mandarin/booking/domain/venue/Hall.java | 15 ++-- .../webapi/show/schedule/POST_specs.java | 68 +++---------------- 15 files changed, 139 insertions(+), 115 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java create mode 100644 src/main/java/org/mandarin/booking/app/HallService.java create mode 100644 src/main/java/org/mandarin/booking/app/QuerydslConfig.java create mode 100644 src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java diff --git a/build.gradle b/build.gradle index b103d64..3674add 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,13 @@ dependencies { testRuntimeOnly 'com.h2database:h2' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + // ---- Querydsl ---- + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + // Ensure APT has Jakarta APIs (some environments require explicit presence) + annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.1.1' + // ---- Security & Auth ---- implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' @@ -50,8 +57,10 @@ dependencies { testImplementation 'io.jsonwebtoken:jjwt-impl:0.12.6' // ---- Lombok ---- - implementation 'org.projectlombok:lombok:1.18.36' + compileOnly 'org.projectlombok:lombok:1.18.36' annotationProcessor 'org.projectlombok:lombok:1.18.36' + testCompileOnly 'org.projectlombok:lombok:1.18.36' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' // ---- Dev Only ---- developmentOnly 'org.springframework.boot:spring-boot-docker-compose' diff --git a/src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java b/src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java new file mode 100644 index 0000000..c23a11d --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.app; + +import lombok.Getter; + +@Getter +public class HallExistCheckEvent { + private final Long hallId; + private boolean exist = false; + + public HallExistCheckEvent(Long hallId) { + this.hallId = hallId; + } + + public void exist() { + this.exist = true; + } +} diff --git a/src/main/java/org/mandarin/booking/app/HallService.java b/src/main/java/org/mandarin/booking/app/HallService.java new file mode 100644 index 0000000..c04a0df --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/HallService.java @@ -0,0 +1,19 @@ +package org.mandarin.booking.app; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.HallQueryRepository; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class HallService { + private final HallQueryRepository queryRepository; + + @EventListener(HallExistCheckEvent.class) + public void hallExistCheckHandler(HallExistCheckEvent event) { + if (queryRepository.existsById(event.getHallId())) { + event.exist(); + } + } +} diff --git a/src/main/java/org/mandarin/booking/app/QuerydslConfig.java b/src/main/java/org/mandarin/booking/app/QuerydslConfig.java new file mode 100644 index 0000000..a622bfb --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/QuerydslConfig.java @@ -0,0 +1,18 @@ +package org.mandarin.booking.app; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index 03650e5..4499cd7 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -1,7 +1,6 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.HallQueryRepository; import org.mandarin.booking.app.persist.ShowCommandRepository; import org.mandarin.booking.app.persist.ShowQueryRepository; import org.mandarin.booking.app.port.ShowRegisterer; @@ -13,6 +12,7 @@ import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.mandarin.booking.domain.venue.HallException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -21,7 +21,6 @@ public class ShowService implements ShowRegisterer { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; - private final HallQueryRepository hallQueryRepository; private final ApplicationEventPublisher applicationEventPublisher; @Override @@ -38,10 +37,14 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); - var hall = hallQueryRepository.getHall(request); + var hallId = request.hallId(); + + checkHallExist(hallId); + checkConflictSchedule(hallId, request); + var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); - show.registerSchedule(hall, command); + show.registerSchedule(hallId, command); var saved = commandRepository.insert(show); return new ShowScheduleRegisterResponse(saved.getId()); } @@ -51,5 +54,19 @@ private void checkDuplicateTitle(String title) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); } } + + private void checkConflictSchedule(Long hallId, ShowScheduleRegisterRequest request) { + if (!queryRepository.canScheduleOn(hallId, request.startAt(), request.endAt())) { + throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); + } + } + + private void checkHallExist(Long hallId) { + var event = new HallExistCheckEvent(hallId); + applicationEventPublisher.publishEvent(event); + if (!event.isExist()) { + throw new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다."); + } + } } diff --git a/src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java b/src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java new file mode 100644 index 0000000..42618ad --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.app; + +import java.time.LocalDateTime; + +public record UnscheduledHallCheckEvent( + Long hallId, + LocalDateTime startAt, + LocalDateTime endAt) { +} diff --git a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java index 59a6e29..0bdb28a 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java @@ -1,9 +1,6 @@ package org.mandarin.booking.app.persist; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; -import org.mandarin.booking.domain.venue.Hall; -import org.mandarin.booking.domain.venue.HallException; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -11,10 +8,9 @@ @Transactional(readOnly = true) @RequiredArgsConstructor public class HallQueryRepository { - private final HallRepository jpaRepository; + private final HallRepository repository; - public Hall getHall(ShowScheduleRegisterRequest request) { - return jpaRepository.findById(request.hallId()) - .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); + public boolean existsById(Long hallId) { + return repository.existsById(hallId); } } diff --git a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java index fccfaed..c34fd54 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java @@ -1,13 +1,10 @@ package org.mandarin.booking.app.persist; -import java.util.Optional; import org.mandarin.booking.domain.venue.Hall; import org.springframework.data.repository.Repository; public interface HallRepository extends Repository { - boolean existsById(Long id); - Hall save(Hall hall); - Optional findById(Long hallId); + boolean existsById(Long id); } diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java index cb2dc31..6e90912 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java @@ -1,6 +1,10 @@ package org.mandarin.booking.app.persist; +import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowException; @@ -12,6 +16,7 @@ @RequiredArgsConstructor public class ShowQueryRepository { private final ShowRepository jpaRepository; + private final JPAQueryFactory queryFactory; public boolean existsByName(String title) { return jpaRepository.existsByTitle(title); @@ -21,4 +26,15 @@ public Show findById(Long showId) { return jpaRepository.findById(showId) .orElseThrow(() -> new ShowException("NOT_FOUND", "존재하지 않는 공연입니다.")); } + + 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; + } } diff --git a/src/main/java/org/mandarin/booking/domain/AbstractEntity.java b/src/main/java/org/mandarin/booking/domain/AbstractEntity.java index 38d560d..50c428e 100644 --- a/src/main/java/org/mandarin/booking/domain/AbstractEntity.java +++ b/src/main/java/org/mandarin/booking/domain/AbstractEntity.java @@ -15,10 +15,12 @@ @ToString(callSuper = true) public abstract class AbstractEntity { @Id - @Getter(onMethod_ = {@Nullable}) + @Nullable + @Getter @GeneratedValue(strategy = IDENTITY) private Long id; + @Override public final boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index 5979d71..b371c2f 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -16,7 +16,6 @@ import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; -import org.mandarin.booking.domain.venue.Hall; @Entity @Getter @@ -73,15 +72,12 @@ public static Show create(ShowCreateCommand command) { ); } - public void registerSchedule(Hall hall, ShowScheduleCreateCommand command) { + public void registerSchedule(Long hallId, ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); } - if (!hall.canScheduleOn(command.startAt(), command.endAt())) { - throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); - } - var schedule = ShowSchedule.create(this, hall, command); + var schedule = ShowSchedule.create(this, hallId, command); this.schedules.add(schedule); } diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java index 38fb590..5501a5e 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -11,7 +11,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; -import org.mandarin.booking.domain.venue.Hall; @Entity @Getter @@ -22,9 +21,7 @@ public class ShowSchedule extends AbstractEntity { @JoinColumn(name = "show_id", nullable = false) private Show show; - @ManyToOne(fetch = LAZY, optional = false) - @JoinColumn(name = "hall_id", nullable = false) - private Hall hall; + private Long hallId; private LocalDateTime startAt; @@ -34,13 +31,13 @@ public class ShowSchedule extends AbstractEntity { private ShowSchedule( Show show, - Hall hall, + Long hallId, LocalDateTime startAt, LocalDateTime endAt, Integer runtimeMinutes ) { this.show = show; - this.hall = hall; + this.hallId = hallId; this.startAt = startAt; this.endAt = endAt; this.runtimeMinutes = runtimeMinutes; @@ -51,10 +48,10 @@ public boolean isConflict(LocalDateTime startAt, LocalDateTime endAt) { && endAt.isAfter(this.startAt); } - static ShowSchedule create(Show show, Hall hall, ShowScheduleCreateCommand command) { + static ShowSchedule create(Show show, Long hallId, ShowScheduleCreateCommand command) { return new ShowSchedule( show, - hall, + hallId, command.startAt, command.endAt, (int) Duration.between(command.startAt, command.endAt).toMinutes() diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index fcd0963..05e9942 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -1,30 +1,16 @@ package org.mandarin.booking.domain.show; import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.Min; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import org.springframework.core.annotation.Order; public record ShowScheduleRegisterRequest( Long showId, Long hallId, LocalDateTime startAt, - LocalDateTime endAt, - - @Min(value = 1, message = "The screening time should be at least 1 minute") - Integer runtimeMinutes + LocalDateTime endAt ) { - @Order(0) @AssertTrue(message = "The end time must be after the start time") private boolean isEndAfterStart() { return endAt.isAfter(startAt); } - - @Order(1) - @AssertTrue(message = "The runtime must match the difference between start and end times") - private boolean isRuntimeValid() { - long between = ChronoUnit.MINUTES.between(startAt, endAt); - return between == runtimeMinutes; - } } diff --git a/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/src/main/java/org/mandarin/booking/domain/venue/Hall.java index d05e77b..6411e01 100644 --- a/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -1,27 +1,20 @@ package org.mandarin.booking.domain.venue; import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; -import org.mandarin.booking.domain.show.ShowSchedule; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Hall extends AbstractEntity { - @OneToMany(mappedBy = "hall") - private List showSchedules = new ArrayList<>(); - public boolean canScheduleOn(LocalDateTime startAt, LocalDateTime endAt) { - return showSchedules.stream() - .noneMatch(schedule -> schedule.isConflict(startAt, endAt)); - } +// public boolean canScheduleOn(LocalDateTime startAt, LocalDateTime endAt) { +// return showSchedules.stream() +// .noneMatch(schedule -> schedule.isConflict(startAt, endAt)); +// } public static Hall create() { return new Hall(); diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index f1c6d3d..93ce424 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -11,7 +11,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; @@ -84,45 +83,6 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(FORBIDDEN); } - @Test - void runtimeMinutes가_0_이하일_경우_BAD_REQUEST를_반환한다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = generateShowScheduleRegisterRequest(show, 0); - - // Act - var response = testUtils.post("/api/show/schedule", request) - .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); - } - - @Test - void runtimeMinutes은_startAt과_endAt의_차이만큼이_아니면_BAD_REQUEST를_반환한다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); - var request = generateShowScheduleRegisterRequest(show, - 100,// runtimeMinutes가 100분으로 startAt, endAt의 차이인 150분과 다름 - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), - hall.getId()); - - // Act - var response = testUtils.post("/api/show/schedule", request) - .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); - assertThat(response.getData()).contains("The runtime must match the difference between start and end times"); - } @Test void startAt이_endAt보다_늦은_경우_BAD_REQUEST를_반환한다( @@ -130,7 +90,7 @@ public class POST_specs { ) { // Arrange var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = generateShowScheduleRegisterRequest(show, 150, + var request = generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 21, 30), LocalDateTime.of(2025, 9, 10, 19, 0), 10L ); @@ -155,8 +115,7 @@ public class POST_specs { 9999L,// 존재하지 않는 showId 10L, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), - 150 + LocalDateTime.of(2025, 9, 10, 21, 30) ); // Act @@ -179,8 +138,7 @@ public class POST_specs { show.getId(), 9999L,// 존재하지 않는 hallId LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), - 150 + LocalDateTime.of(2025, 9, 10, 21, 30) ); // Act @@ -204,8 +162,7 @@ public class POST_specs { show.getId(), hall.getId(), LocalDateTime.of(2023, 9, 10, 19, 0), - LocalDateTime.of(2023, 9, 10, 21, 30), - 150 + LocalDateTime.of(2023, 9, 10, 21, 30) ); // Act @@ -260,35 +217,30 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(INTERNAL_SERVER_ERROR); - assertThat(response.getData()).contains("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); + assertThat(response.getData()).contains("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes) { - return generateShowScheduleRegisterRequest(show, runtimeMinutes, LocalDateTime.of(2025, 9, 10, 19, 0), + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { + return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), 10L); } - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { - return generateShowScheduleRegisterRequest(show, 150); - } private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, long hallId, LocalDateTime startAt, LocalDateTime endAt) { - return generateShowScheduleRegisterRequest(show, - (int) ChronoUnit.MINUTES.between(startAt, endAt), startAt, endAt, hallId); + return generateShowScheduleRegisterRequest(show, startAt, endAt, hallId); } - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, int runtimeMinutes, + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, LocalDateTime startAt, LocalDateTime endAt, long hallId) { return new ShowScheduleRegisterRequest( show.getId(), hallId, startAt, - endAt, - runtimeMinutes + endAt ); } } From e69d4d095be71fee7951d0693e97484212c5fedf Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 10:40:41 +0900 Subject: [PATCH 44/57] remove unused event object --- .../mandarin/booking/app/UnscheduledHallCheckEvent.java | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java diff --git a/src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java b/src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java deleted file mode 100644 index 42618ad..0000000 --- a/src/main/java/org/mandarin/booking/app/UnscheduledHallCheckEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.mandarin.booking.app; - -import java.time.LocalDateTime; - -public record UnscheduledHallCheckEvent( - Long hallId, - LocalDateTime startAt, - LocalDateTime endAt) { -} From 22698835b2eea2cabbb1311f1c2b2c27a9c729cf Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:06:10 +0900 Subject: [PATCH 45/57] add table annotation to Show entity for database mapping --- src/main/java/org/mandarin/booking/domain/show/Show.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index b371c2f..472095e 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -7,6 +7,7 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -18,6 +19,7 @@ import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; @Entity +@Table(name = "shows") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Show extends AbstractEntity { From ce9dd5959629a9b239358cd0dcf338904008b2be Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:06:20 +0900 Subject: [PATCH 46/57] remove logging configuration from application-local.yml --- src/main/resources/application-local.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 70689f7..7586e06 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -21,8 +21,3 @@ jwt: access: 600000 refresh: 1800000 -logging: - level: - org.springframework.boot.web.servlet: INFO - org.springframework.security.web.FilterChainProxy: TRACE - From 66ad25d51109e67477ae93cce8c9b6d1cf40031d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:06:26 +0900 Subject: [PATCH 47/57] add role hierarchy and method security expression handler to SecurityConfig --- .../adapter/security/SecurityConfig.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index f4985b5..79e5e73 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -6,6 +6,10 @@ 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; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -29,9 +33,9 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { @Bean @Order(1) public SecurityFilterChain apiChain(HttpSecurity http, - AuthenticationProvider authenticationProvider, - AuthenticationEntryPoint authenticationEntryPoint, - AccessDeniedHandler accessDeniedHandler) + AuthenticationProvider authenticationProvider, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler) throws Exception { http .securityMatcher("/api/**") @@ -54,6 +58,21 @@ public SecurityFilterChain apiChain(HttpSecurity http, return http.build(); } + @Bean + static RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withDefaultRolePrefix() + .role("ADMIN").implies("DISTRIBUTOR") + .role("DISTRIBUTOR").implies("USER") + .build(); + } + + @Bean + static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); + expressionHandler.setRoleHierarchy(roleHierarchy); + return expressionHandler; + } + @Bean @Order(2) SecurityFilterChain publicChain(HttpSecurity http) throws Exception { From befea66a9f49b0518716d9c6b991f5bcefc71c5b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:06:32 +0900 Subject: [PATCH 48/57] update performance dates and hall ID in show registration and schedule documentation --- docs/specs/api/show_register.md | 12 ++++++------ docs/specs/api/show_schedule_register.md | 16 +++++++--------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index e9d6d19..be7c2c3 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -18,8 +18,8 @@ "rating": "AGE12", "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", "posterUrl": "https://example.com/posters/inception.jpg", - "performanceStartDate": "2024-07-01", - "performanceEndDate": "2024-07-31" + "performanceStartDate": "2025-10-01", + "performanceEndDate": "2025-10-31" } ``` @@ -28,7 +28,7 @@ ```bash curl -i -X POST 'http://localhost:8080/api/show' \ - -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9ESVNUUklCVVRPUiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU2NDM4MjIzLCJleHAiOjE3NTY0Mzg4MjN9.DN0wZb8BdKY-7Grd0KAALXf88KX3iF_tg6UmcfotkFOlbRoRnSuY1nNVUFfZk2TxP0hvju3A8AglK3mt_hnutQ' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ -H 'Content-Type: application/json' \ -d '{ "title": "인셉션", @@ -36,8 +36,8 @@ "rating": "AGE12", "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", "posterUrl": "https://example.com/posters/inception.jpg", - "performanceStartDate": "2024-07-01", - "performanceEndDate": "2024-07-31" + "performanceStartDate": "2025-10-01", + "performanceEndDate": "2025-10-31" }' ``` @@ -52,7 +52,7 @@ "data": { "showId": 1 }, - "timestamp": "2024-06-10T12:34:56.789Z" + "timestamp": "2025-09-10T12:34:56.789Z" } ``` diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index c4ae6e1..1eb9655 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -15,8 +15,8 @@ { "showId": 1, "hallId": 10, - "startAt": "2025-09-10T19:00:00", - "endAt": "2025-09-10T21:30:00", + "startAt": "2025-10-10T19:00:00", + "endAt": "2025-10-10T21:30:00", "runtimeMinutes": 150 } ``` @@ -26,13 +26,12 @@ ```bash curl -i -X POST 'http://localhost:8080/api/show/schedule' \ -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer ' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ -d '{ "showId": 1, - "hallId": 10, - "startAt": "2025-09-10T19:00:00", - "endAt": "2025-09-10T21:30:00", - "runtimeMinutes": 150 + "hallId": 1, + "startAt": "2025-10-10T19:00:00", + "endAt": "2025-10-10T21:30:00" }' ``` @@ -48,8 +47,7 @@ "showId": 1, "hallId": 10, "startAt": "2025-09-10T19:00:00", - "endAt": "2025-09-10T21:30:00", - "runtimeMinutes": 150 + "endAt": "2025-09-10T21:30:00" } ``` From 30b1590d04142498f0fc887fb2ce5bc43436032f Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:08:48 +0900 Subject: [PATCH 49/57] add test for ADMIN user authorization in show schedule registration --- docs/specs/api/show_schedule_register.md | 1 + .../webapi/show/schedule/POST_specs.java | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 1eb9655..05f2b82 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -56,6 +56,7 @@ ### 테스트 - [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 +- [x] ADMIN 권한을 가진 사용자가 올바른 요청을 하는 경우 SUCCESS 상태코드를 반환한다 - [x] 응답 본문에 scheduleId가 포함된다 - [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 93ce424..97bb1c3 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -6,6 +6,7 @@ import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.domain.member.MemberAuthority.ADMIN; import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; import static org.mandarin.booking.domain.member.MemberAuthority.USER; @@ -45,6 +46,27 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(SUCCESS); } + @Test + void ADMIN_권한을_가진_사용자가_올바른_요청을_하는_경우_SUCCESS_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hall = testUtils.insertDummyHall(); + var request = generateShowScheduleRegisterRequest( + show, hall.getId(), + 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(ADMIN)) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } + @Test void 응답_본문에_scheduleId가_포함된다( @Autowired IntegrationTestUtils testUtils From 8a0f0377205ca15c0ef20e6fbe651cf7bab1250e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:29:09 +0900 Subject: [PATCH 50/57] streamline ShowSchedule creation and introduce ShowScheduleCreateCommand --- .../org/mandarin/booking/app/ShowService.java | 2 +- .../mandarin/booking/domain/show/Show.java | 1 - .../booking/domain/show/ShowSchedule.java | 17 ++---- .../show/ShowScheduleCreateCommand.java | 10 ++++ .../booking/domain/show/ShowScheduleTest.java | 58 ------------------- 5 files changed, 15 insertions(+), 73 deletions(-) create mode 100644 src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java delete mode 100644 src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java index 4499cd7..efc3c53 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -9,7 +9,7 @@ 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.ShowSchedule.ShowScheduleCreateCommand; +import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.mandarin.booking.domain.venue.HallException; diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index 472095e..4c2997d 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -16,7 +16,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; -import org.mandarin.booking.domain.show.ShowSchedule.ShowScheduleCreateCommand; @Entity @Table(name = "shows") diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java index 5501a5e..a56a915 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -6,7 +6,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import java.time.Duration; import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,7 +14,7 @@ @Entity @Getter @NoArgsConstructor(access = PROTECTED) -public class ShowSchedule extends AbstractEntity { +class ShowSchedule extends AbstractEntity { @ManyToOne(fetch = LAZY, optional = false) @JoinColumn(name = "show_id", nullable = false) @@ -43,21 +42,13 @@ private ShowSchedule( this.runtimeMinutes = runtimeMinutes; } - public boolean isConflict(LocalDateTime startAt, LocalDateTime endAt) { - return startAt.isBefore(this.endAt) - && endAt.isAfter(this.startAt); - } - static ShowSchedule create(Show show, Long hallId, ShowScheduleCreateCommand command) { return new ShowSchedule( show, hallId, - command.startAt, - command.endAt, - (int) Duration.between(command.startAt, command.endAt).toMinutes() + command.startAt(), + command.endAt(), + command.getRuntimeMinutes() ); } - - public record ShowScheduleCreateCommand(Long showId, LocalDateTime startAt, LocalDateTime endAt) { - } } diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java new file mode 100644 index 0000000..9fc9cb2 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.domain.show; + +import java.time.Duration; +import java.time.LocalDateTime; + +public record ShowScheduleCreateCommand(Long showId, LocalDateTime startAt, LocalDateTime endAt) { + public int getRuntimeMinutes() { + return (int) Duration.between(startAt, endAt).toMinutes(); + } +} diff --git a/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java b/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java deleted file mode 100644 index 077ffe9..0000000 --- a/src/test/java/org/mandarin/booking/domain/show/ShowScheduleTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.mandarin.booking.domain.show; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDateTime; -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class ShowScheduleTest { - - static Stream cases() { - var bStart = LocalDateTime.of(2025, 9, 10, 19, 0); - var bEnd = LocalDateTime.of(2025, 9, 10, 21, 0); - return Stream.of( - // True cases - Arguments.of("exact same", bStart, bEnd, bStart, bEnd, true), - Arguments.of("contained within", bStart, bEnd, bStart.plusMinutes(30), bEnd.minusMinutes(30), true), - Arguments.of("containing (wraps around)", bStart, bEnd, bStart.minusMinutes(15), bEnd.plusMinutes(15), - true), - Arguments.of("overlap at start (ends inside)", bStart, bEnd, bStart.minusMinutes(30), - bStart.plusMinutes(30), true), - Arguments.of("overlap at end (starts inside)", bStart, bEnd, bEnd.minusMinutes(30), - bEnd.plusMinutes(30), true), - - // False cases - Arguments.of("touching before (end equals base.start)", bStart, bEnd, bStart.minusHours(2), bStart, - false), - Arguments.of("touching after (start equals base.end)", bStart, bEnd, bEnd, bEnd.plusHours(2), false), - Arguments.of("completely before", bStart, bEnd, bStart.minusHours(3), bStart.minusHours(1), false), - Arguments.of("completely after", bStart, bEnd, bEnd.plusHours(1), bEnd.plusHours(3), false) - ); - } - - @ParameterizedTest(name = "{0}") - @MethodSource("cases") - void isConflict( - String name, - LocalDateTime baseStart, - LocalDateTime baseEnd, - LocalDateTime targetStart, - LocalDateTime targetEnd, - boolean expected - ) { - // Arrange - var base = ShowSchedule.create(null, null, - new ShowSchedule.ShowScheduleCreateCommand(1L, baseStart, baseEnd)); - - // Act - boolean actual = base.isConflict(targetStart, targetEnd); - - // Assert - assertThat(actual) - .as("case '%s' should be %s", name, expected) - .isEqualTo(expected); - } -} From e0124e4c15088fb544aad03c8e55c1b205cc244e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:31:19 +0900 Subject: [PATCH 51/57] update domain documentation for ShowSchedule registration and related commands --- docs/specs/domain.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/docs/specs/domain.md b/docs/specs/domain.md index e32d100..cf2c9f1 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -26,12 +26,11 @@ _Aggregate Root_ - 포스터 URL(posterUrl) - 공연 시작일(performanceStartDate, yyyy-MM-dd) - 공연 종료일(performanceEndDate, yyyy-MM-dd) +- 공연 스케줄(schedules: List\) #### 행위 - `create(command: ShowCreateCommand)` -- `addSchedule(hallId, startAt, endAt, runtimeOverride)` -- `setCasting(scheduleId, roleName, personName)` -- `changePerformanceWindow(start, end)` +- `registerSchedule(hallId, startAt, endAt, runtimeOverride)` #### 관련 타입 - `ShowCreateCommand` @@ -73,15 +72,10 @@ _Aggregate Root_ - 주소(address) #### 행위 -- `addHall(name)` -- `addSeat(hallId, rowLabel, number, viewGrade, accessibility)` -- `defineGrade(hallId, name)` + +- `create(show,hallId,command)` #### 관련 타입 -- `CreateVenueCommand` -- `AddHallCommand` -- `AddSeatCommand` -- `DefineGradeCommand` --- From c7c5cba46b69a2e74907b2f784f1954214e4af8a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:31:38 +0900 Subject: [PATCH 52/57] update todo list with new tasks and completed items --- docs/todo.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index 75bcf92..c95efa1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,4 +15,11 @@ - [x] 리펙터링 2025.08.29 -- [ ] `/api/members`와 같이 `/api`로 시작하지만 존재하지 않는 엔드포인트에 대한 처리가 `AuthenticationEntryPoint`에서 처리되고 있음 + +- [x] `/api/members`와 같이 `/api`로 시작하지만 존재하지 않는 엔드포인트에 대한 처리가 `AuthenticationEntryPoint`에서 처리되고 있음 + +2025.09.09 + +- [ ] 모듈화 설계 + - [ ] public 떡칠하지 말고 기본 접근제어자 적극 활용 + - [ ] Spring Modulith 사용 가능한지 점검 From 47f9696afc7b93eb801dc139830f863d47508852 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:38:05 +0900 Subject: [PATCH 53/57] document application event usage and encapsulation of entities in DDD approach --- docs/devlog/250908.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/devlog/250908.md diff --git a/docs/devlog/250908.md b/docs/devlog/250908.md new file mode 100644 index 0000000..044bf0b --- /dev/null +++ b/docs/devlog/250908.md @@ -0,0 +1,7 @@ +## 예찬 + +기존의 개발 방식을 고수하면서 이번에는 Aggregate root간의 의존을 최소화 하기 위해 Application Event를 적당히 활용해봤다. 실무에서도 이렇게 잘 쓰이는지는 모르겠다만, 결국 Spring Context에 의해 관리되는 영역인 만큼 그냥 써도 되지 않을까 하는 생각.. + +그리고 이번에 AR를 적극적으로 활용한 DDD를 하려 하다보니 Entity를 package private로 개발하는 방식을 처음으로 써봤다. 도메인 로직들은 통상 AR를 통해 호출되기 때문에, AR를 제외한 나머지 Entity들은 외부에서 직접 접근할 일이 거의 없다는 점에 착안한 것이다. 물론, 이 방식이 무조건 옳다고 생각하지는 않는다. 다만, 이번 프로젝트에서는 이 방식을 채택함으로써 도메인 모델의 캡슐화가 좀 더 강화된 느낌이다. + +마침 AR를 사용한 설계의 장점을 극대화할 겸, 어차피 나중에 한번쯤은 손대려고 했던 모듈을 다음 기능 개발에 도입하려 한다. 이때 AR 기준 접근을 하려 했던 기존의 설계가 빛을 바랄거라고 생각되는데, 이 부분은 다음 개발기를 통해 좀 더 자세히 다뤄보도록 하겠다. From c7b151ec1eea6d0c9cd48b3e9df4ae4698a61d03 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:39:17 +0900 Subject: [PATCH 54/57] add venue and hall registration tasks to todo list --- docs/todo.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/todo.md b/docs/todo.md index c95efa1..408b7d3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -23,3 +23,9 @@ - [ ] 모듈화 설계 - [ ] public 떡칠하지 말고 기본 접근제어자 적극 활용 - [ ] Spring Modulith 사용 가능한지 점검 + +--- + +- [ ] venue register +- [ ] hall register +- [ ] 누가 AR인가? venue vs hall From 446ca5999eaa34b4a7eacec0cebbddee901f5432 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 15:53:30 +0900 Subject: [PATCH 55/57] remove unused Rest Assured dependency from build.gradle --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3674add..f51f311 100644 --- a/build.gradle +++ b/build.gradle @@ -75,7 +75,6 @@ dependencies { testImplementation 'org.mockito:mockito-inline:5.2.0' // ---- API Docs (REST Docs + Rest Assured) ---- - testImplementation 'io.rest-assured:rest-assured:5.5.6' testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.1.0' From 318e61d1bfd76e8544f8a010fb4ed69760e47196 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 18:32:08 +0900 Subject: [PATCH 56/57] simplify JSON example in show schedule registration documentation --- docs/specs/api/show_schedule_register.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 05f2b82..31a990d 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -44,10 +44,7 @@ ```json { - "showId": 1, - "hallId": 10, - "startAt": "2025-09-10T19:00:00", - "endAt": "2025-09-10T21:30:00" + "showId": 1 } ``` From 6341a52fa53064247abd6632a74724b5046821c0 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 8 Sep 2025 18:41:04 +0900 Subject: [PATCH 57/57] update Gradle CI configuration to use 'test' profile for build and test steps --- .github/workflows/gradle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ab4bb09..cfe4c2b 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -31,10 +31,10 @@ jobs: distribution: "temurin" - name: Build (no tests) - run: ./gradlew clean build -x test -x spotbugsMain -x spotbugsTest -Dspring.profiles.active=test + run: ./gradlew clean build -x test -Dspring.profiles.active=test - name: Test - run: ./gradlew test -Dspring.profiles.active=local + run: ./gradlew test -Dspring.profiles.active=test - name: Publish Test Report uses: dorny/test-reporter@v1