From ac75174bb66fd09a207502b9274864aa2b49d3fb Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 15:40:49 +0900 Subject: [PATCH 1/8] add currency and ticket grade validation to show registration --- .../mandarin/booking/utils/TestFixture.java | 19 +- .../booking/webapi/show/POST_specs.java | 192 ++++++++++++++++-- .../java/org/mandarin/booking/Currency.java | 5 + docs/specs/api/show_register.md | 84 +++++--- .../domain/show/ShowRegisterRequest.java | 32 ++- 5 files changed, 279 insertions(+), 53 deletions(-) create mode 100644 common/src/main/java/org/mandarin/booking/Currency.java diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index e8172c1..19a4d84 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -96,7 +96,9 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc "synopsis", "https://example.com/poster.jpg", performanceStartDate, - performanceEndDate + performanceEndDate, + "KRW", + List.of(new ShowRegisterRequest.TicketGradeRequest("VIP", 100000)) ) ); var show = Show.create(hall.getId(), command); @@ -174,7 +176,9 @@ public void generateShows(int showCount, int before, int after) { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now().minusDays(random.nextInt(before)), - LocalDate.now().plusDays(random.nextInt(after)) + LocalDate.now().plusDays(random.nextInt(after)), + "KRW", + List.of(new ShowRegisterRequest.TicketGradeRequest("VIP", 100000)) ); var show = Show.create(hallId, ShowCreateCommand.from(request)); showInsert(show); @@ -191,7 +195,9 @@ public Show generateShowWithNoSynopsis(int scheduleCount) { null, "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of(new ShowRegisterRequest.TicketGradeRequest("VIP", 100000)) ))); for (int i = 0; i < scheduleCount; i++) { @@ -253,7 +259,12 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, S "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of( + new ShowRegisterRequest.TicketGradeRequest("VIP", 180000), + new ShowRegisterRequest.TicketGradeRequest("R", 150000) + ) ); } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 0db493e..759c084 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -9,6 +9,7 @@ import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.domain.show.ShowRegisterRequest.TicketGradeRequest; import java.time.LocalDate; import java.util.List; @@ -17,6 +18,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; @@ -32,21 +34,33 @@ static List nullOrBlankElementRequests() { var hallId = 1L; return List.of( new ShowRegisterRequest(hallId, "", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ), new ShowRegisterRequest(hallId, "공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), - LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), - LocalDate.now().plusDays(1)), + LocalDate.now().plusDays(1), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - null, - LocalDate.now().plusDays(1)), + null, LocalDate.now().plusDays(1), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), null) + LocalDate.now(), null, + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + ) ); } @@ -128,7 +142,8 @@ static List nullOrBlankElementRequests() { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", List.of(new TicketGradeRequest("VIP", 100000)) ); // Act @@ -181,7 +196,9 @@ static List nullOrBlankElementRequests() { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().minusDays(1) + LocalDate.now().minusDays(1), + "KRW", + List.of(new TicketGradeRequest("VIP", 100000)) ); // Act @@ -270,6 +287,144 @@ static List nullOrBlankElementRequests() { assertThat(response.getStatus()).isEqualTo(FORBIDDEN); } + @Test + void ticketGrades가_비어있으면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of() + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void ticketGrade의_name이_중복이면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new TicketGradeRequest("VIP", 180000), + new TicketGradeRequest("VIP", 150000) + ) + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void ticketGrade의_basePrice가_양수가_아니면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new TicketGradeRequest("VIP", 0) + ) + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @ParameterizedTest + @ValueSource(strings = {"", "KW", "EUR", "JPY", "KR", "KOR", "WUN", "123"}) + void currency가_비어있거나_잘못된_값이면_BAD_REQUEST를_반환한다( + String currency, + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + currency, + List.of( + new TicketGradeRequest("VIP", 180000), + new TicketGradeRequest("R", 150000) + ) + ); + + // Act + var response = testUtils.post("/api/show", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + private ShowRegisterRequest validShowRegisterRequest(Long hallId) { return new ShowRegisterRequest( hallId, @@ -279,7 +434,12 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId) { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of( + new TicketGradeRequest("VIP", 180000), + new TicketGradeRequest("R", 150000) + ) ); } @@ -292,8 +452,12 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String title) "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of( + new TicketGradeRequest("VIP", 180000), + new TicketGradeRequest("R", 150000) + ) ); } } - diff --git a/common/src/main/java/org/mandarin/booking/Currency.java b/common/src/main/java/org/mandarin/booking/Currency.java new file mode 100644 index 0000000..069eb2f --- /dev/null +++ b/common/src/main/java/org/mandarin/booking/Currency.java @@ -0,0 +1,5 @@ +package org.mandarin.booking; + +public enum Currency { + WON +} diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index 222edb4..0dd2466 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -1,63 +1,76 @@ ### 요청 -- 메서드: `POST` +- 메서드: POST - 경로: `/api/show` - 헤더 - ``` Content-Type: application/json Authorization: Bearer ``` - 본문 - ```json { - "hallId": 1, - "title": "인셉션", - "type": "MUSICAL", - "rating": "AGE12", - "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "posterUrl": "https://example.com/posters/inception.jpg", - "performanceStartDate": "2025-10-01", - "performanceEndDate": "2025-10-31" + "hallId": 1, + "title": "인셉션", + "type": "MUSICAL", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "performanceStartDate": "2025-10-01", + "performanceEndDate": "2025-10-31", + "currency": "KRW", + "ticketGrades": [ + { "name": "VIP", "basePrice": 180000 }, + { "name": "R", "basePrice": 150000 }, + { "name": "S", "basePrice": 120000 }, + { "name": "A", "basePrice": 90000 } + ] } ``` - -- curl 명령 예시 - +- curl 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/show' \ - -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ - -H 'Content-Type: application/json' \ - -d '{ - "hallId": 1, - "title": "인셉션", - "type": "MUSICAL", - "rating": "AGE12", - "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "posterUrl": "https://example.com/posters/inception.jpg", - "performanceStartDate": "2025-10-01", - "performanceEndDate": "2025-10-31" - }' + curl -i -X POST 'http://localhost:8080/api/show' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "hallId": 1, + "title": "인셉션", + "type": "MUSICAL", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "performanceStartDate": "2025-10-01", + "performanceEndDate": "2025-10-31", + "currency": "KRW", + "ticketGrades": [ + { "name": "VIP", "basePrice": 180000 }, + { "name": "R", "basePrice": 150000 }, + { "name": "S", "basePrice": 120000 }, + { "name": "A", "basePrice": 90000 } + ] + }' ``` +--- + ### 응답 - 상태코드: `200 OK` - 본문 - ```json { - "status": "SUCCESS", - "data": { - "showId": 1 - }, - "timestamp": "2025-09-10T12:34:56.789Z" + "status": "SUCCESS", + "data": { + "showId": 1 + }, + "timestamp": "2025-09-10T12:34:56.789Z" } ``` +--- + ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 @@ -69,3 +82,8 @@ - [x] 중복된 제목의 공연을 등록하면 INTERNAL_SERVER_ERROR가 발생한다 - [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 비ADMIN 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 +- [x] ticketGrades가 비어있으면 BAD_REQUEST를 반환한다 +- [x] ticketGrade의 name이 중복인 경우 BAD_REQUEST를 반환한다 +- [x] ticketGrade의 basePrice가 양수가 아닌 경우 BAD_REQUEST를 반환한다 +- [x] quantity가 양수가 아닌 경우 BAD_REQUEST를 반환한다 +- [x] currency가 비어있거나 잘못된 값이면 BAD_REQUEST를 반환한다 diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index 08d6753..e46828f 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -1,9 +1,17 @@ package org.mandarin.booking.domain.show; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.mandarin.booking.Currency; import org.mandarin.booking.domain.EnumRequest; import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.Type; @@ -34,7 +42,27 @@ public record ShowRegisterRequest( LocalDate performanceStartDate, @NotNull(message = "performance end date is required") - LocalDate performanceEndDate + LocalDate performanceEndDate, + + @NotBlank(message = "currency is required") + @EnumRequest(value = Currency.class, message = "invalid currency") + String currency, + + @NotNull(message = "ticketGrades are required") + @NotEmpty(message = "ticketGrades must not be empty") + List<@Valid TicketGradeRequest> ticketGrades ) { -} + @AssertTrue(message = "ticketGrade names must be unique") + public boolean hasUniqueTicketGradeNames() { + Set names = ticketGrades.stream().map(TicketGradeRequest::name).collect(Collectors.toSet()); + return names.size() == ticketGrades.size(); + } + public record TicketGradeRequest( + @NotBlank(message = "ticketGrade name is required") + String name, + @Positive(message = "basePrice must be positive") + Integer basePrice + ) { + } +} From 15c8905a3c7feb8b19675d9cd50235e35992ed37 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 17:09:27 +0900 Subject: [PATCH 2/8] refactor show registration to use Grade instead of TicketGrade and update currency to KRW --- .../app/NullableQueryFilterBuilder.java | 4 +- .../mandarin/booking/utils/TestFixture.java | 21 +++- .../booking/webapi/show/POST_specs.java | 119 ++++++++++++++---- .../java/org/mandarin/booking/Currency.java | 2 +- .../mandarin/booking/domain/EnumRequest.java | 2 +- .../mandarin/booking/domain/show/Grade.java | 38 ++++++ .../mandarin/booking/domain/show/Show.java | 38 +++++- .../domain/show/ShowRegisterRequest.java | 12 +- 8 files changed, 196 insertions(+), 40 deletions(-) create mode 100644 domain/src/main/java/org/mandarin/booking/domain/show/Grade.java diff --git a/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java index 93189fb..13b0bb2 100644 --- a/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java +++ b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java @@ -41,7 +41,7 @@ public NullableQueryFilterBuilder whenHasText(@Nullable String value, Function show.performanceDate.between(f, t)) +// * 예: whenBoth(of, to, (f, t) -> show.performanceDate.between(f, t)) // */ // public NullableQueryFilterBuilder whenBoth(@Nullable L left, @Nullable R right, BiFunction mapper) { // if (left != null && right != null) { diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 19a4d84..596e0d5 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -98,7 +98,7 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc performanceStartDate, performanceEndDate, "KRW", - List.of(new ShowRegisterRequest.TicketGradeRequest("VIP", 100000)) + List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) ) ); var show = Show.create(hall.getId(), command); @@ -178,7 +178,7 @@ public void generateShows(int showCount, int before, int after) { LocalDate.now().minusDays(random.nextInt(before)), LocalDate.now().plusDays(random.nextInt(after)), "KRW", - List.of(new ShowRegisterRequest.TicketGradeRequest("VIP", 100000)) + List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) ); var show = Show.create(hallId, ShowCreateCommand.from(request)); showInsert(show); @@ -197,7 +197,7 @@ public Show generateShowWithNoSynopsis(int scheduleCount) { LocalDate.now(), LocalDate.now().plusDays(30), "KRW", - List.of(new ShowRegisterRequest.TicketGradeRequest("VIP", 100000)) + List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) ))); for (int i = 0; i < scheduleCount; i++) { @@ -221,6 +221,7 @@ public boolean existsHallName(String hallName) { } public void removeShows() { + entityManager.createQuery("DELETE FROM Grade ").executeUpdate(); entityManager.createQuery("DELETE FROM Show ").executeUpdate(); } @@ -236,6 +237,16 @@ public Hall findHallById(Long hallId) { .getSingleResult(); } + public Show findShowByTitle(String title) { + return entityManager.createQuery( + "SELECT s FROM Show s " + + "JOIN FETCH s.grades grade " + + "WHERE s.title = :title", Show.class) + .setParameter("title", title) + .getSingleResult(); + } + + public boolean isMatchingScheduleInShow(ShowScheduleResponse res, Show show) { return !entityManager.createQuery( "SELECT s FROM ShowSchedule s WHERE s.id = :scheduleId AND s.show.id = :showId", Object.class) @@ -262,8 +273,8 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, S LocalDate.now().plusDays(30), "KRW", List.of( - new ShowRegisterRequest.TicketGradeRequest("VIP", 180000), - new ShowRegisterRequest.TicketGradeRequest("R", 150000) + new ShowRegisterRequest.GradeRequest("VIP", 180000, 100), + new ShowRegisterRequest.GradeRequest("R", 150000, 30) ) ); } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 759c084..3d1e53f 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -1,6 +1,7 @@ package org.mandarin.booking.webapi.show; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.MemberAuthority.DISTRIBUTOR; import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; @@ -9,7 +10,7 @@ import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; -import static org.mandarin.booking.domain.show.ShowRegisterRequest.TicketGradeRequest; +import static org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; import java.time.LocalDate; import java.util.List; @@ -19,6 +20,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; @@ -35,31 +37,31 @@ static List nullOrBlankElementRequests() { return List.of( new ShowRegisterRequest(hallId, "", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ), new ShowRegisterRequest(hallId, "공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), LocalDate.now().plusDays(1), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", null, LocalDate.now().plusDays(1), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), null, - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ) ); } @@ -143,7 +145,7 @@ static List nullOrBlankElementRequests() { "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(30), - "KRW", List.of(new TicketGradeRequest("VIP", 100000)) + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ); // Act @@ -180,6 +182,48 @@ static List nullOrBlankElementRequests() { assertThat(response.getData().showId()).isNotNull(); } + @Test + void 요청한_ticketGrades가_Show에_영속된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 30) + ) + ); + + // Act + testUtils.post( + "/api/show", + request + ) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertSuccess(ShowRegisterResponse.class); + + // Assert + Show show = testFixture.findShowByTitle(request.title()); + assertThat(show.getGrades()) + .hasSize(2) + .extracting("name", "basePrice", "quantity") + .containsExactlyInAnyOrder( + tuple("VIP", 180000, 100), + tuple("R", 150000, 30) + ); + } + @Test void 공연_시작일은_공연_종료일_이후면_INTERNAL_SERVER_ERROR이다( @Autowired IntegrationTestUtils testUtils, @@ -198,7 +242,7 @@ static List nullOrBlankElementRequests() { LocalDate.now(), LocalDate.now().minusDays(1), "KRW", - List.of(new TicketGradeRequest("VIP", 100000)) + List.of(new GradeRequest("VIP", 100000, 100)) ); // Act @@ -326,8 +370,8 @@ static List nullOrBlankElementRequests() { @Autowired TestFixture testFixture ) { // Arrange - var authToken = testUtils.getAuthToken(ADMIN); - var hallId = testFixture.insertDummyHall("userId").getId(); + + var hallId = testFixture.insertDummyHall(testFixture.insertDummyMember(ADMIN).getUserId()).getId(); var request = new ShowRegisterRequest( hallId, UUID.randomUUID().toString().substring(0, 10), @@ -339,8 +383,8 @@ static List nullOrBlankElementRequests() { LocalDate.now().plusDays(30), "KRW", List.of( - new TicketGradeRequest("VIP", 180000), - new TicketGradeRequest("VIP", 150000) + new GradeRequest("VIP", 180000, 100), + new GradeRequest("VIP", 150000, 30) ) ); @@ -349,7 +393,7 @@ static List nullOrBlankElementRequests() { "/api/show", request ) - .withAuthorization(authToken) + .withAuthorization(testUtils.getAuthToken(ADMIN)) .assertFailure(); // Assert @@ -375,7 +419,7 @@ static List nullOrBlankElementRequests() { LocalDate.now().plusDays(30), "KRW", List.of( - new TicketGradeRequest("VIP", 0) + new GradeRequest("VIP", 0, 100) ) ); @@ -391,6 +435,37 @@ static List nullOrBlankElementRequests() { assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + @Test + void quantity가_양수가_아닌_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var hallId = testFixture.insertDummyHall(testFixture.insertDummyMember(ADMIN).getUserId()).getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 100000, -1) + ) + ); + + // Act + var response = testUtils.post("/api/show", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + @ParameterizedTest @ValueSource(strings = {"", "KW", "EUR", "JPY", "KR", "KOR", "WUN", "123"}) void currency가_비어있거나_잘못된_값이면_BAD_REQUEST를_반환한다( @@ -411,8 +486,8 @@ static List nullOrBlankElementRequests() { LocalDate.now().plusDays(30), currency, List.of( - new TicketGradeRequest("VIP", 180000), - new TicketGradeRequest("R", 150000) + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 20) ) ); @@ -437,8 +512,8 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId) { LocalDate.now().plusDays(30), "KRW", List.of( - new TicketGradeRequest("VIP", 180000), - new TicketGradeRequest("R", 150000) + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 30) ) ); } @@ -455,8 +530,8 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String title) LocalDate.now().plusDays(30), "KRW", List.of( - new TicketGradeRequest("VIP", 180000), - new TicketGradeRequest("R", 150000) + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 30) ) ); } diff --git a/common/src/main/java/org/mandarin/booking/Currency.java b/common/src/main/java/org/mandarin/booking/Currency.java index 069eb2f..40e4db2 100644 --- a/common/src/main/java/org/mandarin/booking/Currency.java +++ b/common/src/main/java/org/mandarin/booking/Currency.java @@ -1,5 +1,5 @@ package org.mandarin.booking; public enum Currency { - WON + KRW } diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java index c6754ea..54af983 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -14,7 +14,7 @@ Class> value(); - String message() default "invalid value, must be one from valid enum types"; + String message() default "invalid value, must be one of valid enum types"; Class[] groups() default {}; diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java new file mode 100644 index 0000000..2941d6e --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java @@ -0,0 +1,38 @@ +package org.mandarin.booking.domain.show; + +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PACKAGE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; + +@Entity +@Getter(value = PROTECTED) +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PACKAGE) +@Table(uniqueConstraints = @UniqueConstraint(name = "uk_grade_show_name", columnNames = {"show_id", "name"})) +class Grade extends AbstractEntity { + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "show_id", nullable = false) + private Show show; + + private String name; + + private Integer basePrice; + + private Integer quantity; + + public static Grade of(Show show, GradeRequest gradeRequest) { + return new Grade(show, gradeRequest.name(), gradeRequest.basePrice(), gradeRequest.quantity()); + } +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java index af5d098..b1e318b 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -16,8 +16,10 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.mandarin.booking.Currency; import org.mandarin.booking.domain.AbstractEntity; import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; +import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; @Entity @Table(name = "shows") @@ -45,9 +47,16 @@ public class Show extends AbstractEntity { private LocalDate performanceEndDate; + @Enumerated(EnumType.STRING) + private Currency currency; + + @OneToMany(mappedBy = "show", fetch = LAZY, cascade = ALL) + private List grades = new ArrayList<>(); + private Show(Long hallId, String title, Type type, Rating rating, String synopsis, String posterUrl, LocalDate performanceStartDate, - LocalDate performanceEndDate) { + LocalDate performanceEndDate, + Currency currency) { this.hallId = hallId; this.title = title; this.type = type; @@ -56,6 +65,7 @@ private Show(Long hallId, String title, Type type, Rating rating, String synopsi this.posterUrl = posterUrl; this.performanceStartDate = performanceStartDate; this.performanceEndDate = performanceEndDate; + this.currency = currency; } public void registerSchedule(ShowScheduleCreateCommand command) { @@ -88,7 +98,7 @@ public static Show create(Long hallId, ShowCreateCommand command) { throw new ShowException("공연 시작 날짜는 종료 날짜 이후에 있을 수 없습니다."); } - return new Show( + var show = new Show( hallId, command.getTitle(), command.getType(), @@ -96,8 +106,19 @@ public static Show create(Long hallId, ShowCreateCommand command) { command.getSynopsis(), command.getPosterUrl(), startDate, - endDate + endDate, + command.getCurrency() ); + + var grades = command.getTicketGrades().stream() + .map(gradeReq -> Grade.of(show, gradeReq)) + .toList(); + show.addGrades(grades); + return show; + } + + private void addGrades(List grades) { + this.grades.addAll(grades); } private boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { @@ -122,9 +143,12 @@ public static class ShowCreateCommand { private final String posterUrl; private final LocalDate performanceStartDate; private final LocalDate performanceEndDate; + private final Currency currency; + private final List ticketGrades; private ShowCreateCommand(String title, Type type, Rating rating, String synopsis, String posterUrl, - LocalDate performanceStartDate, LocalDate performanceEndDate) { + LocalDate performanceStartDate, LocalDate performanceEndDate, + Currency currency, List ticketGrades) { this.title = title; this.type = type; this.rating = rating; @@ -132,6 +156,8 @@ private ShowCreateCommand(String title, Type type, Rating rating, String synopsi this.posterUrl = posterUrl; this.performanceStartDate = performanceStartDate; this.performanceEndDate = performanceEndDate; + this.currency = currency; + this.ticketGrades = ticketGrades; } public static ShowCreateCommand from(ShowRegisterRequest request) { @@ -142,7 +168,9 @@ public static ShowCreateCommand from(ShowRegisterRequest request) { request.synopsis(), request.posterUrl(), request.performanceStartDate(), - request.performanceEndDate() + request.performanceEndDate(), + Currency.valueOf(request.currency()), + request.ticketGrades() ); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index e46828f..244c51d 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -50,19 +50,23 @@ public record ShowRegisterRequest( @NotNull(message = "ticketGrades are required") @NotEmpty(message = "ticketGrades must not be empty") - List<@Valid TicketGradeRequest> ticketGrades + List<@Valid GradeRequest> ticketGrades ) { @AssertTrue(message = "ticketGrade names must be unique") public boolean hasUniqueTicketGradeNames() { - Set names = ticketGrades.stream().map(TicketGradeRequest::name).collect(Collectors.toSet()); + Set names = ticketGrades.stream().map(GradeRequest::name).collect(Collectors.toSet()); return names.size() == ticketGrades.size(); } - public record TicketGradeRequest( + public record GradeRequest( @NotBlank(message = "ticketGrade name is required") String name, + @Positive(message = "basePrice must be positive") - Integer basePrice + Integer basePrice, + + @Positive(message = "quantity must be positive") + Integer quantity ) { } } From bffc64116a73f875bf0d98531daaee99e4e35213 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 3 Oct 2025 00:00:25 +0900 Subject: [PATCH 3/8] update show registration documentation to include currency and grade details --- docs/specs/api/show_register.md | 16 ++++++++-------- docs/specs/domain.md | 14 +++++++++----- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index 0dd2466..726e477 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -21,10 +21,10 @@ "performanceEndDate": "2025-10-31", "currency": "KRW", "ticketGrades": [ - { "name": "VIP", "basePrice": 180000 }, - { "name": "R", "basePrice": 150000 }, - { "name": "S", "basePrice": 120000 }, - { "name": "A", "basePrice": 90000 } + { "name": "VIP", "basePrice": 180000, "quantity": 100 }, + { "name": "R", "basePrice": 150000, "quantity": 50 }, + { "name": "S", "basePrice": 120000, "quantity": 30 }, + { "name": "A", "basePrice": 90000, "quantity": 20 } ] } ``` @@ -45,10 +45,10 @@ "performanceEndDate": "2025-10-31", "currency": "KRW", "ticketGrades": [ - { "name": "VIP", "basePrice": 180000 }, - { "name": "R", "basePrice": 150000 }, - { "name": "S", "basePrice": 120000 }, - { "name": "A", "basePrice": 90000 } + { "name": "VIP", "basePrice": 180000, "quantity": 100 }, + { "name": "R", "basePrice": 150000, "quantity": 50 }, + { "name": "S", "basePrice": 120000, "quantity": 30 }, + { "name": "A", "basePrice": 90000, "quantity": 20 } ] }' ``` diff --git a/docs/specs/domain.md b/docs/specs/domain.md index abc8976..df407b3 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -32,6 +32,8 @@ _Aggregate Root_ - 공연 시작일(performanceStartDate, yyyy-MM-dd) - 공연 종료일(performanceEndDate, yyyy-MM-dd) - 공연 스케줄(schedules: List\) +- 통화(currency: KRW) +- 등급(grades: List\) #### 행위 @@ -41,7 +43,7 @@ _Aggregate Root_ #### 관련 타입 - `ShowCreateCommand` - - title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate + - title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate, currency, ticketGrades - `ShowRegisterRequest` / `ShowRegisterResponse` --- @@ -103,16 +105,18 @@ _Entity_ --- -### 좌석등급(TicketGrade) +### 등급(Grade) _Entity_ -- 홀 단위 좌석 등급 +- 쇼 단위 좌석 등급(가격/수량 포함) #### 속성 -- hallId(FK) -- 이름(name) +- showId(FK) +- 이름(name) — 쇼 내 유니크 +- 기본가격(basePrice) +- 수량(quantity) --- From 7c451ba2a122939f8d370c9177f5796cb51e9313 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 3 Oct 2025 00:00:33 +0900 Subject: [PATCH 4/8] add unit tests for BCryptSecurePasswordEncoder functionality --- .../BCryptSecurePasswordEncoderTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java diff --git a/application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java b/application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java new file mode 100644 index 0000000..1f7bf23 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java @@ -0,0 +1,25 @@ +package org.mandarin.booking.adapter.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BCryptSecurePasswordEncoderTest { + @Test + void encode(@Autowired BCryptSecurePasswordEncoder encoder) { + String rawPassword = "password123"; + String encodedPassword = encoder.encode(rawPassword); + assertThat(encodedPassword).isNotEqualTo(rawPassword); + assertThat(encoder.matches(rawPassword, encodedPassword)).isTrue(); + } + + @Test + void matches(@Autowired BCryptSecurePasswordEncoder encoder) { + String rawPassword = "password123"; + String encodedPassword = encoder.encode(rawPassword); + assertThat(encoder.matches(rawPassword, encodedPassword)).isTrue(); + } +} From e88ea9ed71fbd6d14af4beeb863a991f8c60b573 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 3 Oct 2025 00:26:46 +0900 Subject: [PATCH 5/8] refactor access modifiers for Grade, HallCommandRepository, and MemberValidator classes --- .../org/mandarin/booking/app/hall/HallCommandRepository.java | 3 +-- .../java/org/mandarin/booking/app/member/MemberValidator.java | 2 +- .../src/main/java/org/mandarin/booking/domain/show/Grade.java | 2 +- .../src/main/java/org/mandarin/booking/domain/show/Show.java | 3 ++- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java index 8a3dc2e..f9e4685 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java @@ -9,8 +9,7 @@ class HallCommandRepository { private final HallRepository jpaRepository; - - public Hall insert(Hall hall) { + Hall insert(Hall hall) { return jpaRepository.save(hall); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java index 4138632..b9e9696 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java @@ -1,6 +1,6 @@ package org.mandarin.booking.app.member; -public interface MemberValidator { +interface MemberValidator { void checkDuplicateEmail(String email); void checkDuplicateUserId(String userId); diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java index 2941d6e..9e3ed09 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java @@ -32,7 +32,7 @@ class Grade extends AbstractEntity { private Integer quantity; - public static Grade of(Show show, GradeRequest gradeRequest) { + static Grade of(Show show, GradeRequest gradeRequest) { return new Grade(show, gradeRequest.name(), gradeRequest.basePrice(), gradeRequest.quantity()); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java index b1e318b..b6f7064 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -160,7 +160,8 @@ private ShowCreateCommand(String title, Type type, Rating rating, String synopsi this.ticketGrades = ticketGrades; } - public static ShowCreateCommand from(ShowRegisterRequest request) { + public static ShowCreateCommand from( + ShowRegisterRequest request) {//TODO 2025 10 03 00:26:26 : test 코드를 위해 public...? return new ShowCreateCommand( request.title(), Type.valueOf(request.type()), From d96a18d9b85fd5fe0febea44c30aa81370a7f4e8 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 3 Oct 2025 00:32:04 +0900 Subject: [PATCH 6/8] refactor show generation logic in TestFixture and clean up GET_specs --- .../java/org/mandarin/booking/utils/TestFixture.java | 12 ++++++------ .../booking/webapi/show/showId/GET_specs.java | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 596e0d5..33da4a3 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -255,6 +255,12 @@ public boolean isMatchingScheduleInShow(ShowScheduleResponse res, Show show) { .getResultList().isEmpty(); } + private Show generateShow(Long hallId) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + return showInsert(show); + } + private void generateShow(Long hallId, Type type) { var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); var show = Show.create(hallId, ShowCreateCommand.from(request)); @@ -285,12 +291,6 @@ private void generateShow(Long hallId, Rating rating) { showInsert(show); } - public Show generateShow(Long hallId) { - var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); - var show = Show.create(hallId, ShowCreateCommand.from(request)); - return showInsert(show); - } - private Show showInsert(Show show) { entityManager.persist(show); return show; diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java index 83d94b1..b6038cd 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java @@ -47,7 +47,7 @@ class GET_specs { // Arrange var show = testFixture.generateShow(5); var invalidShowId = show.getId() + 9999; - + // Act var response = testUtils.get("/api/show/" + invalidShowId) .assertFailure(); From 0a9b1140f7171e640445998c858f45488d64bb46 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 3 Oct 2025 00:32:59 +0900 Subject: [PATCH 7/8] update documentation for agents and application to improve clarity and consistency --- AGENTS.md | 1 + README.md | 12 ++++---- docs/specs/api/show_list_inquiry.md | 38 ++++++++++++------------ docs/specs/domain.md | 1 - docs/specs/policy/application.md | 14 ++++----- docs/specs/policy/test.md | 46 ++++++++++++++--------------- docs/todo.md | 1 + 7 files changed, 57 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8166f4..bc37ae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ * 라이브러리 지시가 필요하면 **이름만**(예: “use Querydsl”). * **사실 우선, 추측 금지.** 근거 파일이 없으면 “확인 불가”. * 충돌 시 **`docs/specs/policy/application.md`** 우선. +* 응답이 필요한 경우 최대한 한글로 답변. ## 1) 스코프 diff --git a/README.md b/README.md index aaebea4..4d9f895 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile - local: MySQL + JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 - 근거: `application-local.yml`, Docker Compose: [compose.yaml](application/src/main/resources/compose.yaml) - test: H2 메모리 + MySQL 호환 모드 + JPA `ddl-auto: create` - - 근거: `application-test.yml` + - 근거: `application-test.yml` 민감정보는 운영 환경에서 환경변수로 주입하는 것을 권장합니다(로컬에 예시 값 존재). @@ -125,11 +125,11 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile - CI/CD, 코드 포매터, 마이그레이션 도구(Flyway/Liquibase)는 현재 문서/설정 부재로 "확인 불가" 상태입니다. - TODO/메모: [docs/devlog/*](docs/devlog), [docs/todo.md](docs/todo.md) - 권장 향후 작업 - - prod 프로필 구성과 비밀 주입 전략 수립 - - CI 파이프라인(.github/workflows) 도입 - - DB 마이그레이션 도구 채택 및 규약 수립 - - 인증/인가 정책 문서 - 구체화: [docs/specs/policy/authentication.md](docs/specs/policy/authentication.md), [docs/specs/policy/authorization.md](docs/specs/policy/authorization.md) + - prod 프로필 구성과 비밀 주입 전략 수립 + - CI 파이프라인(.github/workflows) 도입 + - DB 마이그레이션 도구 채택 및 규약 수립 + - 인증/인가 정책 문서 + 구체화: [docs/specs/policy/authentication.md](docs/specs/policy/authentication.md), [docs/specs/policy/authorization.md](docs/specs/policy/authorization.md) --- diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 76c070e..15cbd5e 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -23,25 +23,25 @@ - 쿼리 파라미터 - page (선택, 기본=0, 정수 >= 0): 페이지 번호 - size (선택, 기본=10): 페이지 크기 - - type (선택): 공연 유형 (MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) - - rating (선택): 관람 등급 (ALL, AGE12, AGE15, AGE18) - - q (선택): 공연 제목 검색 키워드 - - from (선택, yyyy-MM-dd): 조회 시작일 - - to (선택, yyyy-MM-dd): 조회 종료일 - -> 공연 기간(performanceStartDate~performanceEndDate)이 [from, to] 구간과 겹치는 공연만 반환한다. - - 기간 필터링: 공연 기간 [performanceStartDate, performanceEndDate] 와 조회 기간 [from, to] 가 **겹치면** 포함한다. - - 경계 포함(폐구간): performanceStartDate <= to AND performanceEndDate >= from - - from만 지정 시: performanceEndDate >= from - - to만 지정 시: performanceStartDate <= to - - from > to 인 경우: 400 BAD_REQUEST - - - 정렬: - 1) performanceStartDate DESC - 2) title ASC (title이 unique이므로 마지막 정렬조건으로 충분) - - - 페이지네이션: - - page는 0-기반 인덱스다. - - hasNext = 존재성 여부만 확인 + - type (선택): 공연 유형 (MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) + - rating (선택): 관람 등급 (ALL, AGE12, AGE15, AGE18) + - q (선택): 공연 제목 검색 키워드 + - from (선택, yyyy-MM-dd): 조회 시작일 + - to (선택, yyyy-MM-dd): 조회 종료일 + -> 공연 기간(performanceStartDate~performanceEndDate)이 [from, to] 구간과 겹치는 공연만 반환한다. + - 기간 필터링: 공연 기간 [performanceStartDate, performanceEndDate] 와 조회 기간 [from, to] 가 **겹치면** 포함한다. + - 경계 포함(폐구간): performanceStartDate <= to AND performanceEndDate >= from + - from만 지정 시: performanceEndDate >= from + - to만 지정 시: performanceStartDate <= to + - from > to 인 경우: 400 BAD_REQUEST + + - 정렬: + 1) performanceStartDate DESC + 2) title ASC (title이 unique이므로 마지막 정렬조건으로 충분) + + - 페이지네이션: + - page는 0-기반 인덱스다. + - hasNext = 존재성 여부만 확인 - curl 명령 예시 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index df407b3..fc88495 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -82,7 +82,6 @@ _Entity_ - 공연 시설 - #### 속성 - 이름(name) diff --git a/docs/specs/policy/application.md b/docs/specs/policy/application.md index 89eed0b..4b02406 100644 --- a/docs/specs/policy/application.md +++ b/docs/specs/policy/application.md @@ -70,8 +70,8 @@ - application -> domain (OK), adapter (금지) - adapter -> application 포트(OK), application 서비스/구현(금지), domain(읽기 전용 OK. 단, 비즈니스 수행은 application 경유) - DTO/엔티티 경계: - - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. - - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. + - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. + - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. ## 3. 포트와 어댑터 @@ -90,12 +90,12 @@ - 트랜잭션 경계: application 계층의 유스케이스 서비스 메서드 수준에서 관리(`@Transactional`). 컨트롤러/어댑터에서는 트랜잭션을 시작하지 않습니다. - 검증: - - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. - - 비즈니스/정책 검증: application 또는 domain에서 수행. `Validator` 등의 컴포넌트는 application에 위치. + - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. + - 비즈니스/정책 검증: application 또는 domain에서 수행. `Validator` 등의 컴포넌트는 application에 위치. - 예외: - - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. - - 어댑터/기술 오류는 해당 계층에서 포착하고 domain 의미의 예외로 변환 또는 적절히 매핑. - - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. + - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. + - 어댑터/기술 오류는 해당 계층에서 포착하고 domain 의미의 예외로 변환 또는 적절히 매핑. + - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. - 로깅: 크로스커팅은 internal 계층의 AOP(`LoggingAspect`)에서 처리. 민감 정보(비밀번호, 토큰 등)는 로그 금지. ## 5. 모듈 구조 규칙 diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index b1d0fc3..741d674 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -66,29 +66,29 @@ - 각 테스트는 `IntegrationTestUtils`를 사용해 작성 - `IntegrationTestUtils` 사용 방법은 다음과 같음 - ```java - @Test - void withoutAuth(@Autowired IntegrationTestUtils testUtils) { - // Act & Assert - var response = testUtils.get("/test/without-auth") - .assertSuccess(String.class); - + @Test + void withoutAuth(@Autowired IntegrationTestUtils testUtils) { + // Act & Assert + var response = testUtils.get("/test/without-auth") + .assertSuccess(String.class); + assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); - } - ``` - - ```java - @Test - void failToAuth(@Autowired IntegrationTestUtils testUtils) { - // Arrange - var invalidToken = "invalid token"; - - // Act & Assert - var response = testUtils.get("/test/with-auth") - .withAuthorization(invalidToken) - .assertFailure(); - assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); - } + } ``` + - ```java + @Test + void failToAuth(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidToken = "invalid token"; + + // Act & Assert + var response = testUtils.get("/test/with-auth") + .withAuthorization(invalidToken) + .assertFailure(); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + ``` - 데이터 초기화는 테스트 메서드 단위로 독립되게 유지. H2 메모리 DB가 매 테스트 클래스/메서드 기준으로 깨끗한 상태를 갖도록 설계한다. - 인증이 필요한 엔드포인트는 `JwtTestUtils`로 유효 토큰을 발급하여 헤더 `Authorization: Bearer `를 부착. - 예외/에러 응답은 `GlobalExceptionHandler` 정책에 맞춰 상태코드/본문을 검증. @@ -177,8 +177,8 @@ - 단위 테스트: 대상 패키지에 맞춰 배치, 클래스명 `*Test` - 통합 테스트: 시나리오 중심 폴더 구조 사용 가능 - 예시) - - POST `/api/auth/login`: `application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` - - GET `/api/show`: `application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` + - POST `/api/auth/login`: `application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` + - GET `/api/show`: `application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` - 아키텍처 테스트: `arch/*` --- diff --git a/docs/todo.md b/docs/todo.md index c48b074..a1d6fef 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -28,6 +28,7 @@ - [x] Spring Modulith 사용 가능한지 점검 2025.09.23 + - [ ] hall register - [ ] register with registant name - [ ] register with seats From 8e35a061b3e37d450a4264060188e21520c53f15 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 3 Oct 2025 12:17:01 +0900 Subject: [PATCH 8/8] add grade registration to show creation process and limit access modifiers --- docs/devlog/251003.md | 7 ++ docs/specs/domain.md | 261 ++++-------------------------------------- docs/todo.md | 12 +- 3 files changed, 39 insertions(+), 241 deletions(-) create mode 100644 docs/devlog/251003.md diff --git a/docs/devlog/251003.md b/docs/devlog/251003.md new file mode 100644 index 0000000..1e0bee3 --- /dev/null +++ b/docs/devlog/251003.md @@ -0,0 +1,7 @@ +## 예찬 + +공연을 등록할때 등급을 함께 등록해야 한다는 사실을 뒤늦게 인지 후 기능 수정. + +가능한 최소한의 `public` 메서드를 사용해 클래스를 구현해 필요한 부분만 접근 가능하도록 의존 제한. + +현재까지 테스트 시나리오는 전부 통과하긴 했지만 과연 내 테스트 시나리오가 충분한지에 대한 의문이 남음. diff --git a/docs/specs/domain.md b/docs/specs/domain.md index fc88495..570d4ed 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -7,7 +7,6 @@ - AR 내부 연관은 FK 사용 허용 - AR 간 연관은 "간접 참조(식별자)"만 사용(FK 불허, via XId) -- 결제 도메인 분리 없음: Reservation AR 내부에 Payment/PaymentAttempt/Refund 포함 - Show 공연 기간 명확화: performanceStartDate, performanceEndDate(또는 값객체 PerformanceWindow) 사용 --- @@ -37,8 +36,8 @@ _Aggregate Root_ #### 행위 -- `create(command: ShowCreateCommand)` -- `registerSchedule(hallId, startAt, endAt)` +- `create(command: ShowCreateCommand)`: 공연 생성 +- `registerSchedule(hallId, startAt, endAt)`: 공연에 스케줄 등록 #### 관련 타입 @@ -64,26 +63,27 @@ _Entity_ --- -### 캐스팅(Casting) - -_Entity_ +### 홀(Hall) -- 회차별 배역과 출연자 매핑 +- 공연 시설 #### 속성 -- scheduleId(FK) -- 배역명(roleName) -- 출연자명(personName) +- 이름(name) +- 등록자ID(registantId) --- -### 홀(Hall) +### 구역(Section) -- 공연 시설 +_Entity_ + +- 홀 내부의 구역 + - ex) A관 #### 속성 +- hallId(FK) - 이름(name) --- @@ -96,11 +96,9 @@ _Entity_ #### 속성 -- hallId(FK) -- 열(rowLabel) -- 번호(number) -- 시야등급(viewGrade: NORMAL, PARTIAL_VIEW, OBSTRUCTED) -- 접근성(accessibility: GENERAL, WHEELCHAIR, COMPANION) +- sectionId(FK) +- 열(rowNumber) +- 번호(seatNumber) --- @@ -119,46 +117,7 @@ _Entity_ --- -## 가격표(SchedulePricing) - -_Aggregate Root_ - -- 회차와 좌석등급 조합에 따른 가격 관리 - -#### 속성 - -- scheduleId -- 통화(currency) -- 시작일(validFrom) -- 종료일(validTo) -- 가격정책(pricingPolicy) - -#### 행위 - -- `createFor(scheduleId, currency, validFrom, validTo)` -- `putPrice(ticketGradeId, amount)` -- `removePrice(ticketGradeId)` - -#### 관련 타입 - -- `CreateSchedulePricingCommand` -- `PutTicketPriceCommand` - ---- - -### 가격행(TicketPriceLine) - -_Entity_ - -- 가격표의 라인 항목 - -#### 속성 - -- schedulePricingId(FK) -- ticketGradeId -- 금액(amount) - ---- + ## 회원(Member) @@ -188,123 +147,19 @@ _Aggregate Root_ --- -## 예매(Reservation) - -_Aggregate Root_ - -- 좌석 보류, 확정, 환불 및 결제 관리 - -#### 속성 - -- memberId -- scheduleId -- seatId -- ticketGradeId -- 상태(status: HOLDING, CONFIRMED, REFUNDED, CANCELED) -- 예매일시(reservedAt) -- 홀드만료일시(holdExpiresAt) -- 결제금액(paidAmount) - -#### 행위 - -- `hold(memberId, scheduleId, seatId, ticketGradeId, ttl)` -- `readyPayment(merchantUid, totalAmount)` -- `confirmPaid(merchantUid, approvedAt)` -- `cancelBeforeConfirm()` -- `requestRefund(amount, reason)` - -#### 관련 타입 - -- `HoldReservationCommand` -- `ReadyPaymentCommand` -- `ConfirmPaidCommand` -- `RequestRefundCommand` - ---- - -### 결제(Payment) - -_Entity_ - -- Reservation에 종속되는 결제 정보 - -#### 속성 - -- reservationId(FK) -- 상점거래ID(merchantUid, UNIQUE) -- 총액(totalAmount) -- 상태(status: READY, PENDING, PAID, PARTIALLY_REFUNDED, REFUNDED, FAILED, CANCELED) -- 승인일시(approvedAt) - ---- - -### 결제시도(PaymentAttempt) - -_Entity_ - -- 결제 요청 및 승인/실패 내역 - -#### 속성 - -- paymentId(FK) -- 결제수단(method: CARD, ACCOUNT_TRANSFER, MOBILE, VIRTUAL_ACCOUNT, SIMPLE_PAY) -- 요청금액(requestedAmount) -- 상태(attemptStatus: INIT, REQUESTED, APPROVED, DECLINED, EXPIRED) -- PG거래ID(pgTransactionId, UNIQUE) -- 요청일시(requestedAt) -- 승인일시(approvedAt) -- 실패사유(failureReason) - ---- - -### 환불(Refund) - -_Entity_ - -- 환불 내역 - -#### 속성 - -- paymentId(FK) -- 환불금액(amount) -- 상태(refundStatus: REQUESTED, PENDING, COMPLETED, FAILED) -- 요청일시(requestedAt) -- 완료일시(completedAt) -- 사유(reason) -- PG환불거래ID(pgRefundTransactionId) - ```mermaid erDiagram -%% ======= Aggregates (FK only inside AR) ======= +%% ======= Aggregates ======= %% Show AR Show ||--o{ ShowSchedule : has - ShowSchedule ||--o{ Casting : has -%% UNIQUE(scheduleId, roleName) on Casting - -%% Hall AR (Hall 내부에 Seat/TicketGrade) + Show ||--o{ Grade: has +%% Hall AR Hall ||--o{ Section : has Section ||--o{ Seat : has - Seat ||--o{ TicketGrade : has - Hall ||--o{ TicketGrade : has - -%% Reservation AR (Payment/Attempt/Refund 내부 포함) - Reservation ||--|| Payment : has_one - Payment ||--o{ PaymentAttempt : has_many - Payment ||--o{ Refund : has_many -%% UNIQUE(scheduleId, seatId) on Reservation - -%% SchedulePricing AR (가격표) - SchedulePricing ||--o{ TicketPriceLine : has_many %% ======= Cross-AR indirect references (NO FK) ======= %% ShowSchedule .. Hall : via hallId -%% SchedulePricing .. ShowSchedule : via scheduleId -%% TicketPriceLine .. TicketGrade : via ticketGradeId -%% Reservation .. Member : via memberId -%% Reservation .. ShowSchedule : via scheduleId -%% Reservation .. Seat : via seatId -%% Reservation .. TicketGrade : via ticketGradeId %% ======= Entities ======= @@ -318,6 +173,7 @@ erDiagram string posterUrl date performanceStartDate date performanceEndDate + enum currency "KRW" } ShowSchedule { @@ -328,12 +184,12 @@ erDiagram int runtimeMinutes } - Casting { + Grade { BIGINT id PK - BIGINT scheduleId FK - string roleName - string personName - %% UNIQUE(schedule_id, role_name) + BIGINT showId FK + string name + int basePrice + int quantity } Hall { @@ -356,28 +212,6 @@ erDiagram enum accessibility "GENERAL|WHEELCHAIR|COMPANION" } - TicketGrade { - BIGINT id PK - BIGINT hallId FK - string name - } - - SchedulePricing { - BIGINT id PK - BIGINT scheduleId - string currency - date validFrom - date validTo - string pricingPolicy - } - - TicketPriceLine { - BIGINT id PK - BIGINT schedulePricingId FK - BIGINT ticketGradeId - decimal amount - } - Member { BIGINT id PK string nickName @@ -386,49 +220,4 @@ erDiagram string email string authorities } - - Reservation { - BIGINT id PK - BIGINT memberId - BIGINT scheduleId - BIGINT seatId - BIGINT ticketGradeId - decimal paidAmount - enum status "HOLDING|CONFIRMED|CANCELED|REFUNDED" - datetime reservedAt - datetime holdExpiresAt - %% UNIQUE(schedule_id, seat_id) - } - - Payment { - BIGINT id PK - BIGINT reservationId FK - string merchantUid UK - decimal totalAmount - enum status "READY|PENDING|PAID|PARTIALLY_REFUNDED|REFUNDED|FAILED|CANCELED" - datetime approvedAt - } - - PaymentAttempt { - BIGINT id PK - BIGINT paymentId FK - enum method "CARD|ACCOUNT_TRANSFER|MOBILE|VIRTUAL_ACCOUNT|SIMPLE_PAY" - decimal requestedAmount - enum attemptStatus "INIT|REQUESTED|APPROVED|DECLINED|EXPIRED" - string pgTransactionId UK - datetime requestedAt - datetime approvedAt - string failureReason - } - - Refund { - BIGINT id PK - BIGINT paymentId FK - decimal amount - enum refundStatus "REQUESTED|PENDING|COMPLETED|FAILED" - datetime requestedAt - datetime completedAt - string reason - string pgRefundTransactionId - } ``` diff --git a/docs/todo.md b/docs/todo.md index a1d6fef..25b88c7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -29,10 +29,12 @@ 2025.09.23 -- [ ] hall register - - [ ] register with registant name - - [ ] register with seats +- [x] hall register + - [x] register with registant name + - [x] register with seats ---- +2025.10.02 + +- [x] show register에 grade 추가 고민 -- [ ] show register에 grade 추가 고민 +---