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 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/ diff --git a/build.gradle b/build.gradle index 3b64665..f51f311 100644 --- a/build.gradle +++ b/build.gradle @@ -2,12 +2,17 @@ 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' + 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) @@ -20,6 +25,7 @@ repositories { configurations { byteBuddyAgent + asciidoctorExt } dependencies { @@ -36,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' @@ -44,14 +57,14 @@ 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' - - // ---- 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' @@ -60,6 +73,17 @@ 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' + + // ---- API Docs (REST Docs + Rest Assured) ---- + 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') { @@ -67,4 +91,23 @@ tasks.named('test') { 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/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 기준 접근을 하려 했던 기존의 설계가 빛을 바랄거라고 생각되는데, 이 부분은 다음 개발기를 통해 좀 더 자세히 다뤄보도록 하겠다. diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index da21903..be7c2c3 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": "2025-10-01", + "performanceEndDate": "2025-10-31" + } ``` @@ -20,23 +28,17 @@ ```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": "인셉션", - "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": "2025-10-01", + "performanceEndDate": "2025-10-31" + }' ``` ### 응답 @@ -50,15 +52,16 @@ "data": { "showId": 1 }, - "timestamp": "2024-06-10T12:34:56.789Z" + "timestamp": "2025-09-10T12:34:56.789Z" } ``` ### 테스트 - [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/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md new file mode 100644 index 0000000..31a990d --- /dev/null +++ b/docs/specs/api/show_schedule_register.md @@ -0,0 +1,65 @@ +### 요청 + +- 메서드: `POST` +- 경로: `/api/show/schedule` +- 헤더 + + ``` + Content-Type: application/json + Authorization: Bearer + ``` + +- 본문 예시 + + ```json + { + "showId": 1, + "hallId": 10, + "startAt": "2025-10-10T19:00:00", + "endAt": "2025-10-10T21:30:00", + "runtimeMinutes": 150 + } + ``` + +- curl 명령 예시 + + ```bash + curl -i -X POST 'http://localhost:8080/api/show/schedule' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ + -d '{ + "showId": 1, + "hallId": 1, + "startAt": "2025-10-10T19:00:00", + "endAt": "2025-10-10T21:30:00" + }' + ``` + +--- + +### 응답 + +- 상태코드: `200 OK` +- 본문 예시 + + ```json + { + "showId": 1 + } + ``` + +--- + +### 테스트 + +- [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 +- [x] ADMIN 권한을 가진 사용자가 올바른 요청을 하는 경우 SUCCESS 상태코드를 반환한다 +- [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를 반환한다 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` --- 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/docs/todo.md b/docs/todo.md index 75bcf92..408b7d3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,4 +15,17 @@ - [x] 리펙터링 2025.08.29 -- [ ] `/api/members`와 같이 `/api`로 시작하지만 존재하지 않는 엔드포인트에 대한 처리가 `AuthenticationEntryPoint`에서 처리되고 있음 + +- [x] `/api/members`와 같이 `/api`로 시작하지만 존재하지 않는 엔드포인트에 대한 처리가 `AuthenticationEntryPoint`에서 처리되고 있음 + +2025.09.09 + +- [ ] 모듈화 설계 + - [ ] public 떡칠하지 말고 기본 접근제어자 적극 활용 + - [ ] Spring Modulith 사용 가능한지 점검 + +--- + +- [ ] venue register +- [ ] hall register +- [ ] 누가 AR인가? venue vs hall 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..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/**") @@ -39,7 +43,8 @@ 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/schedule").hasAuthority("ROLE_DISTRIBUTOR") + .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) @@ -53,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 { 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..0892a64 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -2,10 +2,10 @@ 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; +import lombok.extern.slf4j.Slf4j; import org.mandarin.booking.domain.DomainException; import org.mandarin.booking.domain.member.AuthException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -13,12 +13,15 @@ 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) { - return new ErrorResponse(INTERNAL_SERVER_ERROR, ex.getMessage()); + log.error("Domain Exception: {}", (Object[]) ex.getStackTrace()); + var status = ex.getStatus(); + return new ErrorResponse(ApiStatus.valueOf(status), 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/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/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..efc3c53 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -1,31 +1,72 @@ 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.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; @Service @RequiredArgsConstructor public class ShowService implements ShowRegisterer { private final ShowCommandRepository commandRepository; - private final ShowRegisterValidator validator; + private final ShowQueryRepository queryRepository; + private final ApplicationEventPublisher applicationEventPublisher; @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 show = queryRepository.findById(request.showId()); + var hallId = request.hallId(); + + checkHallExist(hallId); + checkConflictSchedule(hallId, request); + var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); + + show.registerSchedule(hallId, command); var saved = commandRepository.insert(show); - return new ShowRegisterResponse(requireNonNull(saved.getId())); + return new ShowScheduleRegisterResponse(saved.getId()); + } + + private void checkDuplicateTitle(String title) { + if (queryRepository.existsByName(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/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/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); + } +} + 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..0bdb28a --- /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 repository; + + 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 new file mode 100644 index 0000000..c34fd54 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/HallRepository.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.app.persist; + +import org.mandarin.booking.domain.venue.Hall; +import org.springframework.data.repository.Repository; + +public interface HallRepository extends Repository { + Hall save(Hall hall); + + 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 242faac..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,13 @@ 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; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -9,9 +16,25 @@ @RequiredArgsConstructor public class ShowQueryRepository { private final ShowRepository jpaRepository; - + private final JPAQueryFactory queryFactory; public boolean existsByName(String title) { return jpaRepository.existsByTitle(title); } + + 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/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/AbstractEntity.java b/src/main/java/org/mandarin/booking/domain/AbstractEntity.java index 1425846..50c428e 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,15 +9,18 @@ import lombok.Getter; import lombok.ToString; import org.hibernate.proxy.HibernateProxy; +import org.jspecify.annotations.Nullable; @MappedSuperclass @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/DomainException.java b/src/main/java/org/mandarin/booking/domain/DomainException.java index 9f54adf..41d7a26 100644 --- a/src/main/java/org/mandarin/booking/domain/DomainException.java +++ b/src/main/java/org/mandarin/booking/domain/DomainException.java @@ -1,7 +1,16 @@ package org.mandarin.booking.domain; +import lombok.Getter; + +@Getter public class DomainException extends RuntimeException { + private String status = "INTERNAL_SERVER_ERROR"; public DomainException(String message) { super(message); } + + public DomainException(String status, String message) { + super(message); + this.status = status; + } } 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/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java index df63be3..4c2997d 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,26 @@ package org.mandarin.booking.domain.show; +import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.FetchType.LAZY; + import jakarta.persistence.Entity; 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; +import java.util.List; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @Entity +@Table(name = "shows") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Show extends AbstractEntity { private String title; @@ -31,6 +38,22 @@ public class Show extends AbstractEntity { private LocalDate performanceEndDate; + @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) + private final List schedules = new ArrayList<>(); + + 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; + 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 +73,20 @@ public static Show create(ShowCreateCommand command) { ); } + public void registerSchedule(Long hallId, ShowScheduleCreateCommand command) { + if (!isInSchedule(command.startAt(), command.endAt())) { + throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); + } + + var schedule = ShowSchedule.create(this, hallId, 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 } @@ -57,5 +94,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..a562fae 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowException.java +++ b/src/main/java/org/mandarin/booking/domain/show/ShowException.java @@ -6,4 +6,8 @@ public class ShowException extends DomainException { public ShowException(String message) { super(message); } + + public ShowException(String status, String message) { + super(status, message); + } } 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/ShowSchedule.java b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java new file mode 100644 index 0000000..a56a915 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -0,0 +1,54 @@ +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) +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; + } + + static ShowSchedule create(Show show, Long hallId, ShowScheduleCreateCommand command) { + return new ShowSchedule( + show, + hallId, + command.startAt(), + command.endAt(), + command.getRuntimeMinutes() + ); + } +} 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/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..05e9942 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.domain.show; + +import jakarta.validation.constraints.AssertTrue; +import java.time.LocalDateTime; + +public record ShowScheduleRegisterRequest( + Long showId, + Long hallId, + LocalDateTime startAt, + LocalDateTime endAt +) { + @AssertTrue(message = "The end time must be after the start time") + private boolean isEndAfterStart() { + return endAt.isAfter(startAt); + } +} 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/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; 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..6411e01 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -0,0 +1,22 @@ +package org.mandarin.booking.domain.venue; + +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Hall extends AbstractEntity { + +// 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/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..e65ebcb --- /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 status, String message) { + super(status, message); + } +} 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 - 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..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,6 +12,7 @@ webEnvironment = RANDOM_PORT, classes = BookingApplication.class ) +@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..fd844b0 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -7,63 +7,68 @@ 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.HallCommandRepository; 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.springframework.boot.test.web.client.TestRestTemplate; +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 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, + ShowCommandRepository showRepository, + HallCommandRepository hallRepository, + 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 +102,31 @@ public Member insertDummyMember() { return this.insertDummyMember(generateUserId(), generatePassword()); } - public String getAuthToken(MemberAuthority ...memberAuthority) { + public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { + var command = ShowCreateCommand.from( + new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 5), + "MUSICAL", + "AGE12", + "synopsis", + "https://example.com/poster.jpg", + performanceStartDate, + performanceEndDate + ) + ); + var show = Show.create(command); + return showRepository.insert(show); + } + + public String getAuthToken(MemberAuthority... memberAuthority) { var member = this.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); - return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) + .accessToken(); + } + + public Hall insertDummyHall() { + var hall = Hall.create(); + return hallRepository.insert(hall); } } 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..2cf1f84 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -2,22 +2,25 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.mandarin.booking.app.TokenUtils; +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; 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 ShowCommandRepository showRepository, + @Autowired HallCommandRepository hallRepository, @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, showRepository, hallRepository, + 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..4bdc415 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,7 +58,7 @@ 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); } @@ -75,15 +70,40 @@ public TestResult withHeader(String headerName, String headerValue) { return this; } - TestResult setContext(TestRestTemplate testRestTemplate, ObjectMapper objectMapper) { - this.testRestTemplate = testRestTemplate; + public TestResult withAuthorization(String token) { + this.withHeader("Authorization", token); + return this; + } + + TestResult setContext(ObjectMapper objectMapper) { this.objectMapper = objectMapper; return this; } + 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 +128,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 +163,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..4f49712 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"; @@ -55,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); @@ -69,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("유효한 토큰이 없습니다."); @@ -82,7 +84,7 @@ void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { // Act var response = testUtils.get("/test/with-auth") - .withHeader("Authorization", invalidBearer) + .withAuthorization(invalidBearer) .assertFailure(); // Assert @@ -98,7 +100,7 @@ void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testU // Act var response = testUtils.get("/test/with-user-role") - .withHeader("Authorization", accessToken) + .withAuthorization(accessToken) .assertFailure(); // Assert @@ -115,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/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/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/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(); 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..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 @@ -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 @@ -37,7 +37,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); // Assert @@ -69,14 +69,14 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var authToken = testUtils.getAuthToken(ADMIN); // Act var response = testUtils.post( "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert @@ -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 @@ -104,7 +104,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert @@ -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 @@ -124,7 +124,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); // Assert @@ -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", @@ -152,7 +152,7 @@ public class POST_specs { "/api/show", request ) - .withHeader("Authorization", authToken) + .withAuthorization(authToken) .assertFailure(); // Assert @@ -166,13 +166,13 @@ 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", 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 new file mode 100644 index 0000000..97bb1c3 --- /dev/null +++ b/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -0,0 +1,268 @@ +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.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; + +import java.time.LocalDate; +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; + +@IntegrationTest +@DisplayName("POST /api/show/schedule") +public class POST_specs { + + @Test + void 올바른_접근_토큰과_유효한_요청을_보내면_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(DISTRIBUTOR)) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + 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 + ) { + // 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(DISTRIBUTOR)) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + assertThat(response.getData().scheduleId()).isNotNull(); + } + + @Test + void 권한이_없는_사용자_토큰으로_요청하면_FORBIDDEN_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var request = generateShowScheduleRegisterRequest(show); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(USER)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(FORBIDDEN); + } + + + @Test + void startAt이_endAt보다_늦은_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var request = generateShowScheduleRegisterRequest(show, + LocalDateTime.of(2025, 9, 10, 21, 30), + LocalDateTime.of(2025, 9, 10, 19, 0), 10L + ); + + // 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"); + } + + @Test + void 존재하지_않는_showId를_보내면_NOT_FOUND_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var request = new ShowScheduleRegisterRequest( + 9999L,// 존재하지 않는 showId + 10L, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + assertThat(response.getData()).contains("존재하지 않는 공연입니다."); + } + + @Test + void 존재하지_않는_hallId를_보내면_NOT_FOUND_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var request = new ShowScheduleRegisterRequest( + show.getId(), + 9999L,// 존재하지 않는 hallId + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + assertThat(response.getData()).contains("해당 공연장을 찾을 수 없습니다."); + } + + @Test + void 공연_기간_범위를_벗어나는_startAt_또는_endAt을_보낼_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); + var hall = testUtils.insertDummyHall(); + var request = new ShowScheduleRegisterRequest( + show.getId(), + hall.getId(), + LocalDateTime.of(2023, 9, 10, 19, 0), + LocalDateTime.of(2023, 9, 10, 21, 30) + ); + + // 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("공연 기간 범위를 벗어나는 일정입니다."); + } + + @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) { + return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), 10L); + } + + + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, + long hallId, + LocalDateTime startAt, + LocalDateTime endAt) { + return generateShowScheduleRegisterRequest(show, startAt, endAt, hallId); + } + + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, + LocalDateTime startAt, + LocalDateTime endAt, long hallId) { + return new ShowScheduleRegisterRequest( + show.getId(), + hallId, + startAt, + endAt + ); + } +}