diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java new file mode 100644 index 0000000..9e0c056 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java @@ -0,0 +1,24 @@ +package org.mandarin.booking.adapter.security; + +import org.mandarin.booking.adapter.AuthorizationRequestMatcherConfigurer; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.stereotype.Component; + +@Component +class ApplicationAuthorizationRequestMatcherConfigurer implements AuthorizationRequestMatcherConfigurer { + @Override + public void authorizeRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth + ) { + auth + .requestMatchers(HttpMethod.POST, "/api/member").permitAll() + .requestMatchers("/api/auth/login").permitAll() + .requestMatchers("/api/auth/reissue").permitAll() + .requestMatchers(HttpMethod.POST, "/api/show/schedule").hasAuthority("ROLE_DISTRIBUTOR") + .requestMatchers(HttpMethod.GET, "/api/show").permitAll() + .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") + .anyRequest().authenticated(); + } +} diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/package-info.java b/application/src/main/java/org/mandarin/booking/adapter/security/package-info.java new file mode 100644 index 0000000..b212696 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/adapter/security/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.mandarin.booking.adapter.security; + +import org.jspecify.annotations.NullMarked; diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 2a98764..a1df9aa 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -1,11 +1,16 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.app.show.ShowFetcher; import org.mandarin.booking.app.show.ShowRegisterer; +import org.mandarin.booking.domain.show.ShowInquiryRequest; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -13,7 +18,12 @@ @RestController @RequestMapping("/api/show") -record ShowController(ShowRegisterer showRegisterer) { +record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { + + @GetMapping + SliceView inquire(@Valid ShowInquiryRequest req) { + return showFetcher.fetchShows(req.page(), req.size(), req.type(), req.rating(), req.q(), req.from(), req.to()); + } @PostMapping ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { diff --git a/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java new file mode 100644 index 0000000..93189fb --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java @@ -0,0 +1,91 @@ +package org.mandarin.booking.app; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.TemporalExpression; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +/** + * Querydsl BooleanBuilder를 감싸는 유틸리티 빌더 클래스 - 조건을 "값이 존재할 때만" 동적으로 추가할 수 있도록 도와줌 - 가독성을 높이고 null/blank 체크를 내부로 숨겨 호출부를 + * 단순하게 만듦 + */ +public class NullableQueryFilterBuilder { + private final BooleanBuilder builder = new BooleanBuilder(); + + private NullableQueryFilterBuilder() { + } + + public BooleanBuilder build() { + return builder; + } + + /** + * 값이 null이 아닐 경우에만 mapper를 통해 Predicate를 생성 후 and 조건으로 추가 예: when(type, t -> show.type.eq(t)) + */ + public NullableQueryFilterBuilder when(@Nullable T value, Function mapper) { + if (value != null) { + builder.and(mapper.apply(value)); + } + return this; + } + + /** + * 문자열이 null이 아니고 공백이 아닐 때만 mapper 적용 예: whenHasText(q, s -> show.title.containsIgnoreCase(s)) + */ + public NullableQueryFilterBuilder whenHasText(@Nullable String value, Function mapper) { + if (value != null && !value.isBlank()) { + builder.and(mapper.apply(value)); + } + return this; + } + + /** + * from, to 둘 다 존재할 때만 between 조건을 추가 + * + * @param from 요청 구간 시작 (예: LocalDate, LocalDateTime) + * @param to 요청 구간 종료 + * @param startExpr 엔티티의 시작 값 경로 (예: show.performanceStartDate) + * @param endExpr 엔티티의 종료 값 경로 (예: show.performanceEndDate) + */ + public > NullableQueryFilterBuilder whenInPeriod( + @Nullable T from, + @Nullable T to, + TemporalExpression startExpr, + TemporalExpression endExpr + ) { + if (from != null) { + builder.and(startExpr.after(from)); + } + if (to != null) { + builder.and(endExpr.before(to)); + } + return this; + } + + public static NullableQueryFilterBuilder builder() { + return new NullableQueryFilterBuilder(); + } + +// /** +// * 두 값이 모두 존재할 때만 mapper 적용 +// * 예: whenBoth(from, to, (f, t) -> show.performanceDate.between(f, t)) +// */ +// public NullableQueryFilterBuilder whenBoth(@Nullable L left, @Nullable R right, BiFunction mapper) { +// if (left != null && right != null) { +// builder.and(mapper.apply(left, right)); +// } +// return this; +// } +// +// /** +// * BooleanExpression을 직접 and 조건으로 추가 +// * (이미 조건식이 준비된 경우 사용) +// */ +// public NullableQueryFilterBuilder and(@Nullable BooleanExpression expr) { +// if (expr != null) { +// builder.and(expr); +// } +// return this; +// } +} diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java similarity index 73% rename from application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java index 34b4b40..7eedc66 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -7,10 +7,10 @@ @Repository @Transactional(readOnly = true) @RequiredArgsConstructor -public class HallQueryRepository { +class HallQueryRepository { private final HallRepository repository; - public boolean existsById(Long hallId) { + boolean existsById(Long hallId) { return repository.existsById(hallId); } } diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java similarity index 67% rename from application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java index a1bd6bd..66a25c5 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java @@ -1,6 +1,6 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; -import org.mandarin.booking.domain.venue.Hall; +import org.mandarin.booking.domain.hall.Hall; import org.springframework.data.repository.Repository; interface HallRepository extends Repository { diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallService.java b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java similarity index 82% rename from application/src/main/java/org/mandarin/booking/app/venue/HallService.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallService.java index 113519d..ce32723 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallService.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java @@ -1,7 +1,7 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.venue.HallException; +import org.mandarin.booking.domain.hall.HallException; import org.springframework.stereotype.Service; @Service diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java similarity index 64% rename from application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java index 001bba6..869d9f2 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; public interface HallValidator { void checkHallExist(Long hallId); diff --git a/application/src/main/java/org/mandarin/booking/app/hall/package-info.java b/application/src/main/java/org/mandarin/booking/app/hall/package-info.java new file mode 100644 index 0000000..b24b425 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/hall/package-info.java @@ -0,0 +1,4 @@ +@NamedInterface("hall") +package org.mandarin.booking.app.hall; + +import org.springframework.modulith.NamedInterface; diff --git a/application/src/main/java/org/mandarin/booking/app/member/AuthService.java b/application/src/main/java/org/mandarin/booking/app/member/AuthService.java index 73b0fa9..b3642c4 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/AuthService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/AuthService.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class AuthService implements AuthUseCase { +class AuthService implements AuthUseCase { private final SecurePasswordEncoder securePasswordEncoder; private final MemberQueryRepository queryRepository; private final TokenUtils tokenUtils; diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java similarity index 94% rename from application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java rename to application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java index 2ad51a0..aee3e42 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java +++ b/application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java @@ -1,9 +1,8 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.app.member; import lombok.RequiredArgsConstructor; import org.mandarin.booking.AuthException; import org.mandarin.booking.adapter.CustomMemberAuthenticationToken; -import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.Member; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java index f666975..e5d62d3 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java @@ -8,10 +8,10 @@ @Repository @Transactional @RequiredArgsConstructor -public class MemberCommandRepository { +class MemberCommandRepository { private final MemberRepository jpaRepository; - public Member insert(Member member) { + Member insert(Member member) { return jpaRepository.save(member); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java index d91c351..2e8fade 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java @@ -9,18 +9,18 @@ @Repository @Transactional(readOnly = true) @RequiredArgsConstructor -public class MemberQueryRepository { +class MemberQueryRepository { private final MemberRepository jpaRepository; - public boolean existsByEmail(String email) { - return jpaRepository.existsByEmail(email); + Optional findByUserId(String userId) { + return jpaRepository.findByUserId(userId); } - public boolean existsByUserId(String userId) { - return jpaRepository.existsByUserId(userId); + boolean existsByEmail(String email) { + return jpaRepository.existsByEmail(email); } - public Optional findByUserId(String userId) { - return jpaRepository.findByUserId(userId); + boolean existsByUserId(String userId) { + return jpaRepository.existsByUserId(userId); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java index 88897a6..f104cfb 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java @@ -6,7 +6,7 @@ @Component @RequiredArgsConstructor -public class MemberRegisterValidator { +class MemberRegisterValidator { private final MemberQueryRepository queryRepository; void checkDuplicateEmail(String email) { diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberService.java b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java index 02083fc..6b0a658 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class MemberService implements MemberRegisterer { +class MemberService implements MemberRegisterer { private final MemberRegisterValidator validator; private final MemberCommandRepository command; private final SecurePasswordEncoder securePasswordEncoder; diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java index 3344547..8c11876 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java @@ -8,10 +8,10 @@ @Repository @Transactional @RequiredArgsConstructor -public class ShowCommandRepository { +class ShowCommandRepository { private final ShowRepository jpaRepository; - public Show insert(Show show) { + Show insert(Show show) { return jpaRepository.save(show); } } diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java b/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java new file mode 100644 index 0000000..89da51c --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.app.show; + +import java.time.LocalDate; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.ShowResponse; + +public interface ShowFetcher { + SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, + LocalDate from, LocalDate to); +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index b397472..c32473c 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -1,40 +1,88 @@ package org.mandarin.booking.app.show; - +import static com.querydsl.jpa.JPAExpressions.select; +import static org.mandarin.booking.domain.hall.QHall.hall; +import static org.mandarin.booking.domain.show.QShow.show; import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.app.NullableQueryFilterBuilder; +import org.mandarin.booking.domain.show.QShowResponse; import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowException; +import org.mandarin.booking.domain.show.ShowResponse; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @Repository @Transactional(readOnly = true) @RequiredArgsConstructor -public class ShowQueryRepository { +class ShowQueryRepository { private final ShowRepository jpaRepository; private final JPAQueryFactory queryFactory; - public boolean existsByName(String title) { + boolean existsByName(String title) { return jpaRepository.existsByTitle(title); } - public Show findById(Long showId) { + Show findById(Long showId) { return jpaRepository.findById(showId) .orElseThrow(() -> new ShowException("NOT_FOUND", "존재하지 않는 공연입니다.")); } - public boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime endAt) { - 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; + boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime endAt) { + return queryFactory + .selectOne() + .from(show) + .leftJoin(show.schedules, showSchedule) + .where(show.hallId.eq(hallId)) + .where(showSchedule.startAt.before(endAt), + showSchedule.endAt.after(startAt)) + .fetchFirst() == null; + } + + SliceView fetch(@Nullable Integer page, + @Nullable Integer size, + @Nullable Type type, + @Nullable Rating rating, + @Nullable String q, + @Nullable LocalDate from, + @Nullable LocalDate to) { + + var builder = NullableQueryFilterBuilder.builder() + .when(type, show.type::eq) + .when(rating, show.rating::eq) + .whenHasText(q, show.title::containsIgnoreCase) + .whenInPeriod(from, to, show.performanceStartDate, show.performanceEndDate) + .build(); + + List results = queryFactory + .select(new QShowResponse( + show.id, + show.title, + show.type, + show.rating, + show.posterUrl, + select(hall.name) + .from(hall) + .where(hall.id.eq(show.hallId)), + show.performanceStartDate, + show.performanceEndDate)) + .from(show) + .where(builder) + .orderBy(show.performanceStartDate.desc(), show.title.asc()) + .offset((long) page * size) + .limit(size + 1) + .fetch(); + + return new SliceView<>(results, page, size); } } diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index e75e755..6113b80 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -1,14 +1,20 @@ package org.mandarin.booking.app.show; import static java.util.Objects.requireNonNull; +import static org.mandarin.booking.domain.EnumUtils.nullableEnum; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.venue.HallValidator; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.app.hall.HallValidator; import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowException; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; @@ -16,15 +22,18 @@ @Service @RequiredArgsConstructor -public class ShowService implements ShowRegisterer { +class ShowService implements ShowRegisterer, ShowFetcher { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; private final HallValidator hallValidator; @Override public ShowRegisterResponse register(ShowRegisterRequest request) { + var hallId = request.hallId(); + + hallValidator.checkHallExist(hallId); var command = ShowCreateCommand.from(request); - var show = Show.create(command); + var show = Show.create(hallId, command); checkDuplicateTitle(show.getTitle()); @@ -35,18 +44,23 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); - var hallId = request.hallId(); - - hallValidator.checkHallExist(hallId); - checkConflictSchedule(hallId, request); + checkConflictSchedule(show.getHallId(), request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); - show.registerSchedule(hallId, command); + show.registerSchedule(command); var saved = commandRepository.insert(show); return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); } + @Override + public SliceView fetchShows(Integer page, Integer size, String type, String rating, + String q, LocalDate from, LocalDate to) { + return queryRepository.fetch(page, size, + nullableEnum(Type.class, type), nullableEnum(Rating.class, rating), + q, from, to); + } + private void checkDuplicateTitle(String title) { if (queryRepository.existsByName(title)) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java deleted file mode 100644 index 5f18b0f..0000000 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.mandarin.booking.app.venue; - -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/application/src/main/java/org/mandarin/booking/app/venue/package-info.java b/application/src/main/java/org/mandarin/booking/app/venue/package-info.java deleted file mode 100644 index 43a5843..0000000 --- a/application/src/main/java/org/mandarin/booking/app/venue/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NamedInterface("venue") -package org.mandarin.booking.app.venue; - -import org.springframework.modulith.NamedInterface; diff --git a/application/src/main/resources/application-test.yml b/application/src/main/resources/application-test.yml index 1036f80..0b18bab 100644 --- a/application/src/main/resources/application-test.yml +++ b/application/src/main/resources/application-test.yml @@ -23,7 +23,8 @@ jwt: access: 600000 refresh: 1800000 -#logging: -# level: -# org.springframework.security: TRACE +logging: + level: + org.springframework.security: TRACE + org.springframework.web: TRACE diff --git a/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java b/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java index 4503884..705be7f 100644 --- a/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java +++ b/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java @@ -14,6 +14,7 @@ import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; import org.mandarin.booking.utils.NoRestDocs; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -94,9 +95,10 @@ void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { } @Test - void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils) { + void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture) { // Arrange - var member = testUtils.insertDummyMember("dummy", "dummy", List.of()); + var member = testFixture.insertDummyMember("dummy", "dummy", List.of()); var accessToken = testUtils.getAuthToken(member); // Act diff --git a/application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java b/application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java new file mode 100644 index 0000000..6678140 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java @@ -0,0 +1,38 @@ +package org.mandarin.booking.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import com.querydsl.core.BooleanBuilder; +import org.junit.jupiter.api.Test; + +class NullableQueryFilterBuilderTest { + + @Test + void whenHasText_null_shouldIgnore() { + var result = NullableQueryFilterBuilder.builder() + .whenHasText(null, s -> fail()) + .build(); + + assertThat(result).isEqualTo(new BooleanBuilder()); + } + + @Test + void whenHasText_empty_shouldIgnore() { + var result = NullableQueryFilterBuilder.builder() + .whenHasText("", s -> fail()) + .build(); + + assertThat(result).isEqualTo(new BooleanBuilder()); + } + + @Test + void whenHasText_blank_shouldIgnore() { + var result = NullableQueryFilterBuilder.builder() + .whenHasText(" ", s -> fail()) + .build(); + + assertThat(result).isEqualTo(new BooleanBuilder()); + } + +} diff --git a/application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java b/application/src/test/java/org/mandarin/booking/app/member/CustomAuthenticationProviderTest.java similarity index 96% rename from application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java rename to application/src/test/java/org/mandarin/booking/app/member/CustomAuthenticationProviderTest.java index de39963..602c382 100644 --- a/application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java +++ b/application/src/test/java/org/mandarin/booking/app/member/CustomAuthenticationProviderTest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.app.member; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java b/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java index 03f274c..3952906 100644 --- a/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java @@ -32,7 +32,8 @@ public record DocsUtils(Environment environment, private static volatile boolean started = false; - public String execute(String method, String path, Object requestBody, Map headers) + public String execute(String method, String path, Object requestBody, Map headers, + Object... pathParams) throws Exception { var baseSnippet = sanitize(method, path, false); var methodSpecificSnippet = sanitize(method, path, true); @@ -55,11 +56,11 @@ public String execute(String method, String path, Object requestBody, Map spec.when().get(path); - case "POST" -> spec.when().post(path); - case "PUT" -> spec.when().put(path); - case "PATCH" -> spec.when().patch(path); - case "DELETE" -> spec.when().delete(path); + case "GET" -> spec.when().get(path, pathParams); + case "POST" -> spec.when().post(path, pathParams); + case "PUT" -> spec.when().put(path, pathParams); + case "PATCH" -> spec.when().patch(path, pathParams); + case "DELETE" -> spec.when().delete(path, pathParams); default -> throw new IllegalArgumentException("Unsupported method: " + method); }; return resp.then().extract().asString(); diff --git a/application/src/test/java/org/mandarin/booking/utils/EnumFixture.java b/application/src/test/java/org/mandarin/booking/utils/EnumFixture.java new file mode 100644 index 0000000..434747e --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/EnumFixture.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.utils; + +public class EnumFixture { + public static > T randomEnum(Class enumClass) { + return enumClass.getEnumConstants()[(int) (Math.random() * enumClass.getEnumConstants().length)]; + } +} diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index 859b7f6..d40ab3c 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -1,38 +1,23 @@ package org.mandarin.booking.utils; -import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import java.time.LocalDate; import java.util.Collection; import java.util.List; -import java.util.UUID; import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.TokenHolder; import org.mandarin.booking.adapter.TokenUtils; -import org.mandarin.booking.app.member.MemberCommandRepository; -import org.mandarin.booking.app.show.ShowCommandRepository; -import org.mandarin.booking.app.venue.HallCommandRepository; import org.mandarin.booking.domain.member.Member; -import org.mandarin.booking.domain.member.Member.MemberCreateCommand; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.domain.show.Show; -import org.mandarin.booking.domain.show.Show.ShowCreateCommand; -import org.mandarin.booking.domain.show.ShowRegisterRequest; -import org.mandarin.booking.domain.venue.Hall; -import org.springframework.test.util.ReflectionTestUtils; -public record IntegrationTestUtils(MemberCommandRepository memberRepository, - ShowCommandRepository showRepository, - HallCommandRepository hallRepository, - TokenUtils tokenUtils, - SecurePasswordEncoder securePasswordEncoder, - ObjectMapper objectMapper, - DocsUtils docsUtils) { +public record IntegrationTestUtils( + TestFixture fixture, + TokenUtils tokenUtils, + ObjectMapper objectMapper, + DocsUtils docsUtils) { public IntegrationTestUtils { objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } @@ -50,13 +35,13 @@ public TestResult post(String path, T request) { } public String getValidRefreshToken() { - var member = insertDummyMember(generateUserId(), generatePassword()); + var member = fixture.insertDummyMember(generateUserId(), generatePassword()); return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .refreshToken(); } public String getAuthToken() { - var member = this.insertDummyMember(); + var member = fixture.insertDummyMember(); return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .accessToken(); } @@ -71,61 +56,10 @@ public TokenHolder getUserToken(String userId, String nickname, return tokenUtils.generateToken(userId, nickname, authorities); } - public Member insertDummyMember(String userId, String password) { - var command = new MemberCreateCommand( - generateNickName(), - userId, - password, - generateEmail() - ); - return memberRepository.insert( - Member.create(command, securePasswordEncoder) - ); - } - - public Member insertDummyMember(String userId, String nickName, List authorities) { - var command = new MemberCreateCommand( - nickName, - userId, - generatePassword(), - generateEmail() - ); - var member = Member.create(command, securePasswordEncoder); - ReflectionTestUtils.setField(member, "authorities", authorities); - return memberRepository.insert( - member - ); - } - - public Member insertDummyMember() { - return this.insertDummyMember(generateUserId(), generatePassword()); - } - - public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { - var command = ShowCreateCommand.from( - new ShowRegisterRequest( - UUID.randomUUID().toString().substring(0, 5), - "MUSICAL", - "AGE12", - "synopsis", - "https://example.com/poster.jpg", - performanceStartDate, - performanceEndDate - ) - ); - var show = Show.create(command); - return showRepository.insert(show); - } - public String getAuthToken(MemberAuthority... memberAuthority) { - var member = this.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); + var member = fixture.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .accessToken(); } - - public Hall insertDummyHall() { - var hall = Hall.create(); - return hallRepository.insert(hall); - } } diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java index 5eff281..7bdba88 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java @@ -41,14 +41,15 @@ void post_echo_success( @Test @DisplayName("insertDummyMember로 저장한 회원을 test-only endpoint(/test/member/exists)로 검증한다") void insertDummyMember_and_verify_exists( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange String userId = "it_utils_user" + System.currentTimeMillis(); String password = "P@ssw0rd!"; // save member using utils - var saved = integrationUtils.insertDummyMember(userId, password); + var saved = testFixture.insertDummyMember(userId, password); assertThat(saved).isNotNull(); // Act diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index 0cbd7ad..741a42f 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -1,10 +1,9 @@ package org.mandarin.booking.utils; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.mandarin.booking.adapter.TokenUtils; -import org.mandarin.booking.app.member.MemberCommandRepository; -import org.mandarin.booking.app.show.ShowCommandRepository; -import org.mandarin.booking.app.venue.HallCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; @@ -12,15 +11,19 @@ @TestConfiguration public class TestConfig { + @PersistenceContext + private EntityManager entityManager; + @Bean - public IntegrationTestUtils integrationTestUtils(@Autowired MemberCommandRepository memberRepository, - @Autowired ShowCommandRepository showRepository, - @Autowired HallCommandRepository hallRepository, + public IntegrationTestUtils integrationTestUtils(@Autowired TestFixture testFixture, @Autowired TokenUtils tokenUtils, - @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper, @Autowired DocsUtils docsUtils) { - return new IntegrationTestUtils(memberRepository, showRepository, hallRepository, - tokenUtils, securePasswordEncoder, objectMapper, docsUtils); + return new IntegrationTestUtils(testFixture, tokenUtils, objectMapper, docsUtils); + } + + @Bean + public TestFixture testFixture(@Autowired SecurePasswordEncoder securePasswordEncoder) { + return new TestFixture(entityManager, securePasswordEncoder); } } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java new file mode 100644 index 0000000..b23c7f2 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -0,0 +1,203 @@ +package org.mandarin.booking.utils; + +import static org.mandarin.booking.utils.EnumFixture.randomEnum; +import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; +import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; + +import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.stream.IntStream; +import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.domain.hall.Hall; +import org.mandarin.booking.domain.member.Member; +import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.SecurePasswordEncoder; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +import org.mandarin.booking.domain.show.Show.Type; +import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class TestFixture { + private final EntityManager entityManager; + private final SecurePasswordEncoder securePasswordEncoder; + + public TestFixture(EntityManager entityManager, SecurePasswordEncoder securePasswordEncoder) { + this.entityManager = entityManager; + this.securePasswordEncoder = securePasswordEncoder; + } + + public Member insertDummyMember(String userId, String password) { + var command = new MemberCreateCommand( + generateNickName(), + userId, + password, + generateEmail() + ); + return memberInsert( + Member.create(command, securePasswordEncoder) + ); + } + + public Member insertDummyMember(String userId, String nickName, List authorities) { + var command = new MemberCreateCommand( + nickName, + userId, + generatePassword(), + generateEmail() + ); + var member = Member.create(command, securePasswordEncoder); + ReflectionTestUtils.setField(member, "authorities", authorities); + return memberInsert( + member + ); + } + + public Member insertDummyMember() { + return this.insertDummyMember(generateUserId(), generatePassword()); + } + + public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { + var hall = insertDummyHall(); + var command = ShowCreateCommand.from( + new ShowRegisterRequest( + hall.getId(), + UUID.randomUUID().toString().substring(0, 5), + "MUSICAL", + "AGE12", + "synopsis", + "https://example.com/poster.jpg", + performanceStartDate, + performanceEndDate + ) + ); + var show = Show.create(hall.getId(), command); + return showInsert(show); + } + + public Hall insertDummyHall() { + var hall = Hall.create("hall name"); + entityManager.persist(hall); + return hall; + } + + public List generateShows(int showCount) { + var hall = insertDummyHall(); + return IntStream.range(0, showCount) + .mapToObj(i -> generateShow(hall.getId())) + .toList(); + } + + public void generateShows(int showCount, Type type) { + var hall = insertDummyHall(); + IntStream.range(0, showCount) + .forEach(i -> generateShow(hall.getId(), type)); + } + + public void generateShows(int showCount, Rating rating) { + var hall = insertDummyHall(); + IntStream.range(0, showCount) + .forEach(i -> generateShow(hall.getId(), rating)); + } + + public void generateShows(int showCount, String titlePart) { + Random random = new Random(); + var hall = insertDummyHall(); + IntStream.range(0, showCount) + .forEach(i -> { + var request = validShowRegisterRequest(hall.getId(), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name()); + var show = Show.create(hall.getId(), ShowCreateCommand.from(request)); + ReflectionTestUtils.setField(show, "title", + (char) random.nextInt('a', 'z') + titlePart + (char) random.nextInt('a', 'z')); + showInsert(show); + }); + } + + public void generateShows(int showCount, int before, int after) { + Random random = new Random(); + var hall = insertDummyHall(); + var hallId = hall.getId(); + IntStream.range(0, showCount) + .forEach(i -> { + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now().minusDays(random.nextInt(before)), + LocalDate.now().plusDays(random.nextInt(after)) + ); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + showInsert(show); + }); + } + + public boolean existsHallName(String name) { + return (entityManager.createQuery("SELECT COUNT(h) FROM Hall h WHERE h.name = :name") + .setParameter("name", name) + .getSingleResult() instanceof Long count) && count > 0; + } + + public void removeShows() { + entityManager.createQuery("DELETE FROM Show ").executeUpdate(); + } + + public Member findMemberByUserId(String userId) { + return entityManager.createQuery("SELECT m FROM Member m WHERE m.userId = :userId", Member.class) + .setParameter("userId", userId) + .getSingleResult(); + } + + private void generateShow(Long hallId, Type type) { + var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + showInsert(show); + } + + private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, String rating) { + return new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + type, + rating, + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + } + + private void generateShow(Long hallId, Rating rating) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), rating.name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + showInsert(show); + } + + private Show generateShow(Long hallId) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + return showInsert(show); + } + + private Show showInsert(Show show) { + entityManager.persist(show); + return show; + } + + private Member memberInsert(Member member) { + entityManager.persist(member); + return member; + } +} diff --git a/application/src/test/java/org/mandarin/booking/utils/TestResult.java b/application/src/test/java/org/mandarin/booking/utils/TestResult.java index d4068f8..0af6368 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestResult.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestResult.java @@ -138,9 +138,13 @@ private ErrorResponse readErrorResponse() { } private SuccessResponse<@NonNull T> readSuccessResponse(String raw, TypeReference typeRef) { + if (isErrorEnvelope(raw)) { + fail("Expected SuccessResponse but got ErrorResponse: " + raw); + } try { var inner = objectMapper.getTypeFactory().constructType(typeRef); var wrapper = objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, inner); + return objectMapper.readValue(raw, wrapper); } catch (JsonProcessingException primary) { try { @@ -173,6 +177,7 @@ private String getResponse() { @FunctionalInterface public interface Executor { - String execute(String path, Object request, Map headers) throws Exception; + String execute(String path, Object request, Map headers) + throws Exception; } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java index 8fe2193..1eec28b 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java @@ -18,10 +18,10 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mandarin.booking.TokenHolder; -import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.AuthRequest; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -30,13 +30,14 @@ public class POST_specs { @Test void 올바른_요청을_보내면_200_OK_상태코드를_반환한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var request = new AuthRequest(generateUserId(), generatePassword()); // save member - integrationUtils.insertDummyMember(request.userId(), request.password()); + testFixture.insertDummyMember(request.userId(), request.password()); // Act var response = integrationUtils.post( @@ -107,7 +108,8 @@ public class POST_specs { @Test void 요청_본문의_password가_userId에_해당하는_password가_일치하지_않으면_401_Unauthorized_상태코드를_반환한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var userId = generateUserId(); @@ -116,7 +118,7 @@ public class POST_specs { var request = new AuthRequest(userId, invalidPassword); //save member - integrationUtils.insertDummyMember(userId, generatePassword()); + testFixture.insertDummyMember(userId, generatePassword()); // Act var response = integrationUtils.post( @@ -131,11 +133,12 @@ public class POST_specs { @Test void 성공적인_로그인_후_응답에_accessToken과_refreshToken가_포함되어야_한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var request = new AuthRequest(generateUserId(), generatePassword()); - integrationUtils.insertDummyMember(request.userId(), request.password()); + testFixture.insertDummyMember(request.userId(), request.password()); // Act var response = integrationUtils.post( @@ -151,12 +154,13 @@ public class POST_specs { @Test void 전달된_토큰은_유효한_JWT_형식이어야_한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var userId = generateUserId(); var password = generatePassword(); - integrationUtils.insertDummyMember(userId, password); + testFixture.insertDummyMember(userId, password); var request = new AuthRequest(userId, password); @@ -177,11 +181,12 @@ public class POST_specs { @Test void 전달된_토큰은_만료되지_않아야한다( @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture, @Value("${jwt.token.secret}") String secretKey) { // Arrange var userId = generateUserId(); var password = generatePassword(); - integrationUtils.insertDummyMember(userId, password); + testFixture.insertDummyMember(userId, password); SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes()); var request = new AuthRequest(userId, password); @@ -207,13 +212,13 @@ public class POST_specs { @Test void 전달된_토큰에는_사용자의_userId가_포함되어야_한다( @Autowired IntegrationTestUtils integrationUtils, - @Value("${jwt.token.secret}") String secretKey, - @Autowired MemberQueryRepository memberRepository + @Autowired TestFixture testFixture, + @Value("${jwt.token.secret}") String secretKey ) { // Arrange var userId = generateUserId(); var password = generatePassword(); - integrationUtils.insertDummyMember(userId, password); + testFixture.insertDummyMember(userId, password); SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes()); var request = new AuthRequest(userId, password); @@ -235,7 +240,7 @@ public class POST_specs { assertThat(refreshTokenClaims.getPayload().get("userId")).isNotNull(); var currentUserId = accessTokenClaims.getPayload().get("userId").toString(); - var savedMember = memberRepository.findByUserId(currentUserId).orElseThrow(); + var savedMember = testFixture.findMemberByUserId(currentUserId); assertThat(savedMember).isNotNull(); } diff --git a/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java index b883a18..8573d22 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java @@ -23,6 +23,7 @@ import org.mandarin.booking.domain.member.ReissueRequest; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; @@ -103,10 +104,11 @@ public class POST_specs { @Test void 요청_토큰의_서명이_잘못된_경우_401_Unauthorized가_발생한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - testUtils.insertDummyMember(generateUserId(), generatePassword()); + testFixture.insertDummyMember(generateUserId(), generatePassword()); var invalidRefresh = "invalid_refresh_token"; var request = new ReissueRequest(invalidRefresh); diff --git a/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index 3ba2b08..7a1d810 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.MemberRegisterRequest; import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -16,6 +15,7 @@ import org.mandarin.booking.utils.IntegrationTestUtils; import org.mandarin.booking.utils.MemberFixture.NicknameGenerator; import org.mandarin.booking.utils.MemberFixture.PasswordGenerator; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -43,7 +43,7 @@ public class POST_specs { @Test void 올바른_회원가입_요청을_하면_데이터베이스에_회원_정보가_저장된다( @Autowired IntegrationTestUtils testUtils, - @Autowired MemberQueryRepository memberRepository + @Autowired TestFixture testFixture ) { // Arrange var request = generateRequest(); @@ -55,7 +55,7 @@ public class POST_specs { ).assertSuccess(MemberRegisterResponse.class); // Assert - var matchingMember = memberRepository.findByUserId(request.userId()).orElseThrow(); + var matchingMember = testFixture.findMemberByUserId(request.userId()); assertThat(matchingMember).isNotNull(); } @@ -187,9 +187,9 @@ public class POST_specs { @Test void 비밀번호가_올바르게_암호화_된다( - @Autowired MemberQueryRepository memberRepository, @Autowired SecurePasswordEncoder securePasswordEncoder, - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange String rawPassword = PasswordGenerator.generatePassword(); @@ -207,7 +207,7 @@ public class POST_specs { ).assertSuccess(MemberRegisterResponse.class); // Assert - var savedMember = memberRepository.findByUserId(request.userId()).orElseThrow(); + var savedMember = testFixture.findMemberByUserId(request.userId()); assertThat(securePasswordEncoder.matches(rawPassword, savedMember.getPasswordHash())).isTrue(); } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java new file mode 100644 index 0000000..ba6b4b1 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -0,0 +1,449 @@ +package org.mandarin.booking.webapi.show; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatStream; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; +import org.mandarin.booking.domain.show.ShowResponse; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("GET /api/show") +@IntegrationTest +public class GET_specs { + +// @Test +// void Authorization_헤더가_없더라도_접근하더라도_401_Unauthorized가_발생하지_않는다( +// @Autowired IntegrationTestUtils testUtils +// ) { +// // Arrange +// +// // Act +// var response = testUtils.get("/api/show") +// .assertSuccess(new TypeReference>() { +// }); +// +// // Assert +// assertThat(response.getStatus()).isNotEqualTo(UNAUTHORIZED); +// } + + + @BeforeEach + void setUp(@Autowired TestFixture testFixture) { + testFixture.removeShows(); + } + + @Test + void 기본_요청_시_첫번째_페이지의_10건이_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10); + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().size()).isEqualTo(10); + } + + @Test + void 공연이_존재하지_않을_경우_빈_contents_hasNext는false를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + assertThat(response.getData().contents()).isEmpty(); + } + + @Test + void 실제로_저장된_공연_정보가_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + List showRegisterResponses = testFixture.generateShows(10); + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + for (Show res : showRegisterResponses) { + assertThat(response.getData().contents().stream() + .anyMatch(show -> show.showId().equals(res.getId()))) + .isTrue(); + } + } + + @Test + void 초과_페이지_요청_시_빈_contents와_hasNext는_false를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?page=2") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + assertThat(response.getData().contents()).isEmpty(); + } + + @Test + void size가_100보다_큰_요청_시_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?size=101") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void page가_0보다_작은_요청_시_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?page=-1") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void size가_1보다_작은_요청_시_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?size=0") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 부적절한_type으로_요청하는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?type=AAA")// invalid type + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 부적절한_rating으로_요청하는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?rating=AAA") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 지정된_type이_존재한다면_해당_type_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10, Type.MUSICAL); + + // Act + var response = testUtils.get("/api/show?type=MUSICAL") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream()) + .allMatch(show -> show.type().equals(Type.MUSICAL)); + } + + @Test + void 지정된_rating이_존재한다면_해당_rating_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10, Rating.ALL); + + // Act + var response = testUtils.get("/api/show?rating=ALL") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream()) + .allMatch(show -> show.rating().equals(Rating.ALL)); + + } + + @Test + void q값이_비어있지_않다면_제목에_q가_포함된_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var titlePart = "titlePart"; + testFixture.generateShows(10, titlePart); + + // Act + var response = testUtils.get("/api/show?q=titlePart") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream().map(ShowResponse::title)) + .allMatch(title -> title.contains(titlePart)); + + } + + @Test + void 여러_건이_존재할_경우_performanceStartDate_DESC_title_ASC_순으로_정렬된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20); + + // Act + var response = testUtils.get("/api/show?size=20") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream()) + .isSortedAccordingTo(Comparator.comparing(ShowResponse::performanceStartDate) + .thenComparing(ShowResponse::title)); + } + + @Test + void from에서_to까지_기간과_겹치는_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().minusDays(1).toString(); + var to = LocalDate.now().plusDays(1).toString(); + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().stream()) + .allMatch(show -> !show.performanceStartDate().isAfter(LocalDate.parse(to))// 결과의 시작일은 요청 종요일보다 이후가 아니며 + && !show.performanceEndDate() + .isBefore(LocalDate.parse(from)));//결과의 종료일은 요청 시작일보다 이전이 아니다 + } + + @Test + void from만_지정_시_해당_일자_이후_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + + // Act + var response = testUtils.get("/api/show?from=" + LocalDate.now().plusDays(1)) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().stream()) + .allMatch(show -> show.performanceStartDate().isAfter(LocalDate.now().plusDays(1))); + } + + @Test + void to만_지정_시_해당_일자_이전_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + + // Act + var response = testUtils.get("/api/show?to=" + LocalDate.now().minusDays(3)) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().stream().map(ShowResponse::performanceEndDate)) + .allMatch(date -> date.isBefore(LocalDate.now().minusDays(3))); + } + + @Test + void 기간이_서로_맞물리지_않는_경우_빈_contents를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().plusDays(11).toString(); + var to = LocalDate.now().plusDays(21).toString(); + + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + assertThat(response.getData().contents()).isEmpty(); + } + + @Test + void from_또는_to_형식이_잘못된_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + var to = LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void from이_to이후인_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().plusDays(1).toString(); + var to = LocalDate.now().minusDays(1).toString(); + + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void q가_공백인_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, "title"); + var q = " "; + + // Act + var response = testUtils.get("/api/show?q=" + q) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 마지막_페이지에서_hasNext가_거짓으로_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20); + + // Act + var response = testUtils.get("/api/show?page=1") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + } + + @Test + void 마지막_페이지가_아닌_경우_hasNext가_참으로_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20); + + // Act + var response = testUtils.get("/api/show?page=0") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isTrue(); + } + + @Test + void hallName은_존재하는_공연장_이름이_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10); + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents()).isNotEmpty(); + assertThatStream(response.getData().contents().stream()) + .allMatch(show -> !show.hallName().equals("null")) + .allMatch(showResponse -> testFixture.existsHallName(showResponse.hallName())); + } +} diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 5159b3b..51c7b44 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -4,6 +4,7 @@ import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; @@ -18,6 +19,7 @@ import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -25,31 +27,35 @@ public class POST_specs { static List nullOrBlankElementRequests() { + var hallId = 1L; return List.of( - new ShowRegisterRequest("", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), + new ShowRegisterRequest(hallId, "공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", null, + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + null, LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), null) ); } @Test void 올바른_요청을_보내면_status가_SUCCESS이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(); + var request = validShowRegisterRequest(testFixture.insertDummyHall().getId()); // Act var response = testUtils.post( @@ -65,10 +71,12 @@ static List nullOrBlankElementRequests() { @Test void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var request = validShowRegisterRequest(); + var hallId = testFixture.insertDummyHall().getId(); + var request = validShowRegisterRequest(hallId); // Act var response = testUtils.post( @@ -104,11 +112,14 @@ static List nullOrBlankElementRequests() { @Test void 허용되지_않은_type이면_BAD_REQUEST이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall().getId(); var request = new ShowRegisterRequest( + hallId, "공연 제목", "MOVIE", // invalid type "AGE12", @@ -132,11 +143,13 @@ static List nullOrBlankElementRequests() { @Test void 올바른_요청을_보내면_응답_본문에_showId가_존재한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(); + var hallId = testFixture.insertDummyHall().getId(); + var request = validShowRegisterRequest(hallId); // Act var response = testUtils.post( @@ -152,11 +165,14 @@ static List nullOrBlankElementRequests() { @Test void 공연_시작일은_공연_종료일_이후면_INTERNAL_SERVER_ERROR이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall().getId(); var request = new ShowRegisterRequest( + hallId, "공연 제목", "MUSICAL", "AGE12", @@ -183,11 +199,12 @@ static List nullOrBlankElementRequests() { @SuppressWarnings("NonAsciiCharacters") @Test void 중복된_제목의_공연을_등록하면_INTERNAL_SERVER_ERROR가_발생한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(); + var request = validShowRegisterRequest(testFixture.insertDummyHall().getId()); testUtils.post( "/api/show", request @@ -195,7 +212,7 @@ static List nullOrBlankElementRequests() { .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); - var duplicateTitleRequest = validShowRegisterRequest(request.title()); + var duplicateTitleRequest = validShowRegisterRequest(request.hallId(), request.title()); // Act var response = testUtils.post( @@ -210,8 +227,30 @@ static List nullOrBlankElementRequests() { assertThat(response.getData()).contains("이미 존재하는 공연 이름입니다:"); } - private ShowRegisterRequest validShowRegisterRequest() { + @Test + void 존재하지_않는_hallId를_보내면_NOT_FOUND_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = 1000L; + var request = validShowRegisterRequest(hallId); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + } + + private ShowRegisterRequest validShowRegisterRequest(Long hallId) { return new ShowRegisterRequest( + hallId, UUID.randomUUID().toString().substring(0, 10), "MUSICAL", "AGE12", @@ -222,8 +261,9 @@ private ShowRegisterRequest validShowRegisterRequest() { ); } - private ShowRegisterRequest validShowRegisterRequest(String title) { + private ShowRegisterRequest validShowRegisterRequest(Long hallId, String title) { return new ShowRegisterRequest( + hallId, title, "MUSICAL", "AGE12", diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 580f485..aedebdd 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -20,6 +20,7 @@ import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -28,13 +29,13 @@ public class POST_specs { @Test void 올바른_접근_토큰과_유효한_요청을_보내면_SUCCESS_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest( - show, requireNonNull(hall.getId()), + show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -49,13 +50,13 @@ show, requireNonNull(hall.getId()), @Test void ADMIN_권한을_가진_사용자가_올바른_요청을_하는_경우_SUCCESS_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest( - show, requireNonNull(hall.getId()), + show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -70,13 +71,13 @@ show, requireNonNull(hall.getId()), @Test void 응답_본문에_scheduleId가_포함된다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest( - show, requireNonNull(hall.getId()), + show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -91,10 +92,11 @@ show, requireNonNull(hall.getId()), @Test void 권한이_없는_사용자_토큰으로_요청하면_FORBIDDEN_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show); // Act @@ -109,13 +111,14 @@ show, requireNonNull(hall.getId()), @Test void startAt이_endAt보다_늦은_경우_BAD_REQUEST를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 21, 30), - LocalDateTime.of(2025, 9, 10, 19, 0), 10L + LocalDateTime.of(2025, 9, 10, 19, 0) ); // Act @@ -130,13 +133,13 @@ show, requireNonNull(hall.getId()), @Test void 존재하지_않는_showId를_보내면_NOT_FOUND_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId - 10L, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30) ); @@ -151,39 +154,15 @@ show, requireNonNull(hall.getId()), 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( - requireNonNull(show.getId()), - 9999L,// 존재하지 않는 hallId - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30) - ); - - // Act - var response = testUtils.post("/api/show/schedule", request) - .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(NOT_FOUND); - assertThat(response.getData()).contains("해당 공연장을 찾을 수 없습니다."); - } - @Test void 공연_기간_범위를_벗어나는_startAt_또는_endAt을_보낼_경우_BAD_REQUEST를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), - requireNonNull(hall.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), LocalDateTime.of(2023, 9, 10, 21, 30) ); @@ -200,25 +179,22 @@ show, requireNonNull(hall.getId()), @Test void 동일한_hallId와_시간이_겹치는_회차를_등록하려_하면_INTERNAL_SERVER_ERROR를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var hall = testUtils.insertDummyHall(); - - var show = testUtils.insertDummyShow( + var show = testFixture.insertDummyShow( LocalDate.now().minusDays(1), LocalDate.now().plusDays(10) ); - var request = generateShowScheduleRegisterRequest(show, requireNonNull(hall.getId()), + var request = generateShowScheduleRegisterRequest( + show, LocalDateTime.now(), LocalDateTime.now().plusHours(2) ); - var anotherShow = testUtils.insertDummyShow( - LocalDate.now().minusDays(2), - LocalDate.now().plusDays(30) - ); - var nextRequest = generateShowScheduleRegisterRequest(anotherShow, hall.getId(), + var nextRequest = generateShowScheduleRegisterRequest( + show, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(3) ); @@ -245,23 +221,15 @@ show, requireNonNull(hall.getId()), private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), 10L); + LocalDateTime.of(2025, 9, 10, 21, 30)); } private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, - long hallId, LocalDateTime startAt, LocalDateTime endAt) { - return generateShowScheduleRegisterRequest(show, startAt, endAt, hallId); - } - - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, - LocalDateTime startAt, - LocalDateTime endAt, long hallId) { return new ShowScheduleRegisterRequest( - requireNonNull(show.getId()), - hallId, + show.getId(), startAt, endAt ); diff --git a/docs/devlog/250922.md b/docs/devlog/250922.md new file mode 100644 index 0000000..aea31b7 --- /dev/null +++ b/docs/devlog/250922.md @@ -0,0 +1,10 @@ +## 예찬 + +공연 목록 조회라는 사실상 가장 조회 성능에 민감한 부분이 될 부분을 개발했다. 추후 쿼리튜닝이나 캐싱을 통해 개선을 해야겠지만 당장에는 기능 구현한것만으로 만족. + +모듈화를 하면서 일부 누락되었던 package-private화도 마무리. 이제 얼추 외곽이 잡힌 애플리케이션이라는 생각이 든다. + +뭔가 페이징 조회 객체를 통일하는 과정에서 Spring이 제공하는 PageResponse를 커스텀해서 쓸까도 고민하긴 했는데 내가 만단 POJO 객체를 더 잘 쓰는게 더 좋지 않을까? 하는생각으로 그냥 새로 +만들어봤다. 제어하기에도 쉽고 부수효과 확인도 편하니까. + +이제 앞으로가 문제다. 요구사항을 잡기가 쉽지가 않은거같다. 도메인 개요에 대한 어느정도 문서화를 하고나서 다시 해야할까? 이런생각도 들긴 한다. diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md new file mode 100644 index 0000000..4f9e46e --- /dev/null +++ b/docs/specs/api/show_list_inquiry.md @@ -0,0 +1,122 @@ +# 공연 목록 조회 + +--- + +## 개요 + +- 공연(Show)의 목록을 조회한다. +- 검색 조건(type, rating, q, from~to)을 통해 필터링할 수 있으며, 결과는 페이지네이션된다. +- 반환 데이터는 공연 요약 정보로, 상세(회차, 가격 등)는 포함하지 않는다. + +--- + +## 요청 + +- 메서드: `GET` +- 경로: `/api/show` +- 헤더 + + ``` + Content-Type: application/json + ``` + +- 쿼리 파라미터 + - 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 ASC + 2) title ASC(title이 unique기 때문에 마지막 정렬조건으로 충분) + + - 페이지네이션: + - page는 0-기반 인덱스다. + - hasNext = 존재성 여부만 확인 + +- curl 명령 예시 + + ```bash + curl -i -X GET 'http://localhost:8080/api/show?page=0&size=5&type=MUSICAL&from=2025-10-01&to=2025-10-31&q=라라' \ + -H 'Content-Type: application/json' + ``` + +--- + +## 응답 + +- 상태코드: `200 OK` + - 본문 예시 + + ```json + { + "status": "SUCCESS", + "data": { + "contents": [ + { + "showId": 1, + "title": "라라랜드", + "type": "MUSICAL", + "rating": "ALL", + "posterUrl": "https://example.com/posters/lalaland.jpg", + "hallName": "샤롯데씨어터", + "performanceStartDate": "2025-10-05", + "performanceEndDate": "2025-11-05" + }, + { + "showId": 2, + "title": "라라랜드 2", + "type": "MUSICAL", + "rating": "AGE12", + "posterUrl": "https://example.com/posters/lalaland2.jpg", + "hallName": "샤롯데씨어터", + "performanceStartDate": "2025-10-10", + "performanceEndDate": "2025-11-10" + } + ], + "page": 0, + "size": 5, + "hasNext": false + }, + "timestamp": "2025-09-17T12:34:56.789Z" + } + + ``` + +--- + +## 테스트 + +- [x] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 +- [x] 기본 요청 시 첫번째 페이지의 10건이 반환된다 +- [x] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 +- [x] 실제로 저장된 공연 정보가 조회된다 +- [x] 초과 페이지 요청 시 빈 contents와 hasNext=false를 반환한다 +- [x] size가 100보다 큰 요청 시 BAD_REQUEST를 반환한다 +- [x] page가 0보다 작은 요청 시 BAD_REQUEST를 반환한다 +- [x] size가 1보다 작은 요청 시 BAD_REQUEST를 반환한다 +- [x] 부적절한 type으로 요청하는 경우 BAD_REQUEST를 반환한다 +- [x] 부적절한 rating으로 요청하는 경우 BAD_REQUEST를 반환한다 +- [x] 지정된 type이 존재한다면 해당 type 공연만 조회된다 +- [x] 지정된 rating이 존재한다면 해당 rating 공연만 조회된다 +- [x] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 +- [x] 여러 건이 존재할 경우 performanceStartDate DESC, title ASC 순으로 정렬된다 +- [x] from에서 to까지 기간과 겹치는 공연만 조회된다 +- [x] from만 지정 시 해당 일자 이후 공연만 조회된다 +- [x] to만 지정 시 해당 일자 이전 공연만 조회된다 +- [x] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 +- [x] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 +- [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 +- [x] q가 공백인 경우 BAD_REQUEST를 반환한다 +- [x] 마지막 페이지에서 hasNext가 거짓으로 반환된다 +- [x] 마지막 페이지가 아닌 경우 hasNext가 참으로 반환된다 +- [x] hallName은 존재하는 공연장 이름이 조회된다 diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index be7c2c3..cc0858a 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -13,6 +13,7 @@ ```json { + "hallId": 1, "title": "인셉션", "type": "MUSICAL", "rating": "AGE12", @@ -31,6 +32,7 @@ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ -H 'Content-Type: application/json' \ -d '{ + "hallId": 1, "title": "인셉션", "type": "MUSICAL", "rating": "AGE12", @@ -65,3 +67,4 @@ - [x] 올바른 요청을 보내면 응답 본문에 showId가 존재한다 - [x] 공연 시작일은 공연 종료일 이후면 INTERNAL_SERVER_ERROR이다 - [x] 중복된 제목의 공연을 등록하면 INTERNAL_SERVER_ERROR가 발생한다 +- [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 31a990d..933a655 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -14,7 +14,6 @@ ```json { "showId": 1, - "hallId": 10, "startAt": "2025-10-10T19:00:00", "endAt": "2025-10-10T21:30:00", "runtimeMinutes": 150 @@ -29,7 +28,6 @@ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ -d '{ "showId": 1, - "hallId": 1, "startAt": "2025-10-10T19:00:00", "endAt": "2025-10-10T21:30:00" }' @@ -60,6 +58,5 @@ - [x] runtimeMinutes은 startAt과 endAt의 차이만큼이 아니면 BAD_REQUEST를 반환한다 - [x] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 -- [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index cab60ce..5b888fc 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -76,34 +76,13 @@ _Entity_ --- -## 공연장(Venue) - -_Aggregate Root_ - -- 공연 시설 - -#### 속성 - -- 이름(name) -- 주소(address) - -#### 행위 - -- `create(show,hallId,command)` - -#### 관련 타입 - ---- - ### 홀(Hall) -_Entity_ +- 공연 시설 -- 공연장 내부의 개별 공간 #### 속성 -- venueId(FK) - 이름(name) --- @@ -300,8 +279,7 @@ erDiagram ShowSchedule ||--o{ Casting : has %% UNIQUE(scheduleId, roleName) on Casting -%% Venue AR (Venue 내부에 Hall/Seat/TicketGrade) - Venue ||--o{ Hall : has +%% Hall AR (Hall 내부에 Seat/TicketGrade) Hall ||--o{ Seat : has Hall ||--o{ TicketGrade : has @@ -327,6 +305,7 @@ erDiagram Show { BIGINT id PK + BIGINT hallId string title enum type "MUSICAL|PLAY|CONCERT|OPERA|DANCE|CLASSICAL|ETC" enum rating "ALL|AGE12|AGE15|AGE18" @@ -339,7 +318,6 @@ erDiagram ShowSchedule { BIGINT id PK BIGINT showId FK - BIGINT hallId datetime startAt datetime endAt int runtimeMinutes @@ -353,15 +331,8 @@ erDiagram %% UNIQUE(schedule_id, role_name) } - Venue { - BIGINT id PK - string name - string address - } - Hall { BIGINT id PK - BIGINT venueId FK string name } diff --git a/docs/todo.md b/docs/todo.md index cd8330d..2280043 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -27,8 +27,11 @@ - [x] public 떡칠하지 말고 기본 접근제어자 적극 활용 - [x] Spring Modulith 사용 가능한지 점검 +2025.09.23 +- [ ] hall register + - [ ] register with registerer name + - [ ] register with seats + --- -- [ ] venue register -- [ ] hall register -- [ ] 누가 AR인가? venue vs hall +- [ ] show register에 grade 추가 고민 diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java index e824e70..c6754ea 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -7,16 +7,18 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = EnumRequestValidator.class) public @interface EnumRequest { Class> value(); - String message() default "invalid value, must be one of valid enum types"; + String message() default "invalid value, must be one from valid enum types"; Class[] groups() default {}; Class[] payload() default {}; + + boolean nullable() default false; } diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java index 06e98aa..8924669 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java @@ -3,31 +3,38 @@ import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import org.jspecify.annotations.NullUnmarked; - +import org.jspecify.annotations.Nullable; @NullUnmarked public class EnumRequestValidator implements ConstraintValidator { private Class> clazz; private String message; + private boolean nullable; @Override public void initialize(EnumRequest constraintAnnotation) { - clazz = constraintAnnotation.value(); - message = constraintAnnotation.message(); + this.clazz = constraintAnnotation.value(); + this.message = constraintAnnotation.message(); + this.nullable = constraintAnnotation.nullable(); } @Override - public boolean isValid(String s, ConstraintValidatorContext context) { - clazz.getEnumConstants(); - for (Enum enumConstant : clazz.getEnumConstants()) { - if (enumConstant.name().equals(s)) { - return true; + public boolean isValid(@Nullable String value, ConstraintValidatorContext context) { + if (value == null) { + return nullable; + } + + Enum[] constants = clazz.getEnumConstants(); + if (constants != null) { + for (Enum e : constants) { + if (e.name().equals(value)) { + return true; + } } } context.disableDefaultConstraintViolation(); - context.buildConstraintViolationWithTemplate(message) - .addConstraintViolation(); + context.buildConstraintViolationWithTemplate(message).addConstraintViolation(); return false; } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java b/domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java new file mode 100644 index 0000000..7ce7921 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java @@ -0,0 +1,12 @@ +package org.mandarin.booking.domain; + +import org.jspecify.annotations.Nullable; + +public final class EnumUtils { + public static > T nullableEnum(Class enumClass, @Nullable String value) { + if (value == null) { + return null; + } + return Enum.valueOf(enumClass, value); + } +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java similarity index 56% rename from domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java rename to domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java index 6d508c4..7c74e77 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java @@ -1,7 +1,8 @@ -package org.mandarin.booking.domain.venue; +package org.mandarin.booking.domain.hall; import jakarta.persistence.Entity; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @@ -9,9 +10,11 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { + private String name; - public static Hall create() { - return new Hall(); + public static Hall create(String name) { + return new Hall(name); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java b/domain/src/main/java/org/mandarin/booking/domain/hall/HallException.java similarity index 82% rename from domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java rename to domain/src/main/java/org/mandarin/booking/domain/hall/HallException.java index 62dd466..9747b60 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallException.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.domain.venue; +package org.mandarin.booking.domain.hall; import org.mandarin.booking.DomainException; diff --git a/domain/src/main/java/org/mandarin/booking/domain/member/Member.java b/domain/src/main/java/org/mandarin/booking/domain/member/Member.java index ec526a8..e29b759 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/member/Member.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/Member.java @@ -6,6 +6,7 @@ import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.NaturalId; import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.domain.AbstractEntity; @@ -16,6 +17,7 @@ public class Member extends AbstractEntity { private String nickName; + @NaturalId private String userId; private String passwordHash; diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java index d5970a0..e35291a 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -24,19 +24,29 @@ public class Show extends AbstractEntity { @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) private final List schedules = new ArrayList<>(); + + private Long hallId; + private String title; + @Enumerated(EnumType.STRING) private Type type; + @Enumerated(EnumType.STRING) private Rating rating; + private String synopsis; + private String posterUrl; + private LocalDate performanceStartDate; + private LocalDate performanceEndDate; - private Show(String title, Type type, Rating rating, String synopsis, String posterUrl, + private Show(Long hallId, String title, Type type, Rating rating, String synopsis, String posterUrl, LocalDate performanceStartDate, LocalDate performanceEndDate) { + this.hallId = hallId; this.title = title; this.type = type; this.rating = rating; @@ -46,16 +56,16 @@ private Show(String title, Type type, Rating rating, String synopsis, String pos this.performanceEndDate = performanceEndDate; } - public void registerSchedule(Long hallId, ShowScheduleCreateCommand command) { + public void registerSchedule(ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); } - var schedule = ShowSchedule.create(this, hallId, command); + var schedule = ShowSchedule.create(this, command); this.schedules.add(schedule); } - public static Show create(ShowCreateCommand command) { + public static Show create(Long hallId, ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); var endDate = command.getPerformanceEndDate(); @@ -64,6 +74,7 @@ public static Show create(ShowCreateCommand command) { } return new Show( + hallId, command.getTitle(), command.getType(), command.getRating(), diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java new file mode 100644 index 0000000..bcfec70 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java @@ -0,0 +1,53 @@ +package org.mandarin.booking.domain.show; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDate; +import org.jspecify.annotations.Nullable; +import org.mandarin.booking.domain.EnumRequest; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; + +public record ShowInquiryRequest( + @Min(0) @Nullable + Integer page, + + @Min(1) @Nullable @Max(100) + Integer size, + + @EnumRequest(value = Type.class, nullable = true) + String type, + + @EnumRequest(value = Rating.class, nullable = true) + String rating, + + @Nullable + String q, + + @DateTimeFormat(iso = ISO.DATE) + @Nullable + LocalDate from, + + @DateTimeFormat(iso = ISO.DATE) + @Nullable + LocalDate to +) { + public ShowInquiryRequest { + if (page == null) { + page = 0; + } + if (size == null) { + size = 10; + } + + if ((from != null && to != null) && from.isAfter(to)) { + throw new ShowException("BAD_REQUEST", "from 는 to 보다 과거만 가능합니다."); + } + q = (q == null) ? null : q.trim(); + if (q != null && q.isEmpty()) { + throw new ShowException("BAD_REQUEST", "q는 공백일 수 없습니다."); + } + } +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index 0c6c920..08d6753 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -9,6 +9,9 @@ import org.mandarin.booking.domain.show.Show.Type; public record ShowRegisterRequest( + @NotNull(message = "hall id is required") + Long hallId, + @NotBlank(message = "title is required") String title, diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java new file mode 100644 index 0000000..fe6f251 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java @@ -0,0 +1,22 @@ +package org.mandarin.booking.domain.show; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDate; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; + +public record ShowResponse( + Long showId, + String title, + Type type, + Rating rating, + String posterUrl, + String hallName, + LocalDate performanceStartDate, + LocalDate performanceEndDate +) { + + @QueryProjection + public ShowResponse { + } +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java index a56a915..9dfeea4 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -20,8 +20,6 @@ class ShowSchedule extends AbstractEntity { @JoinColumn(name = "show_id", nullable = false) private Show show; - private Long hallId; - private LocalDateTime startAt; private LocalDateTime endAt; @@ -30,22 +28,19 @@ class ShowSchedule extends AbstractEntity { private ShowSchedule( Show show, - Long hallId, LocalDateTime startAt, LocalDateTime endAt, Integer runtimeMinutes ) { this.show = show; - this.hallId = hallId; this.startAt = startAt; this.endAt = endAt; this.runtimeMinutes = runtimeMinutes; } - static ShowSchedule create(Show show, Long hallId, ShowScheduleCreateCommand command) { + static ShowSchedule create(Show show, ShowScheduleCreateCommand command) { return new ShowSchedule( show, - hallId, command.startAt(), command.endAt(), command.getRuntimeMinutes() diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 05e9942..7b7ba25 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -5,7 +5,6 @@ public record ShowScheduleRegisterRequest( Long showId, - Long hallId, LocalDateTime startAt, LocalDateTime endAt ) { diff --git a/domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java b/domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java new file mode 100644 index 0000000..ebee1b3 --- /dev/null +++ b/domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java @@ -0,0 +1,107 @@ +package org.mandarin.booking.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.lang.reflect.Field; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class EnumRequestValidatorBranchTest { + + private static Validator validator; + + @BeforeAll + static void init() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + validator = factory.getValidator(); + } + } + + @Test + void nullAllowed_passes() { + var dto = new DtoNullableTrue(null); + assertThat(validator.validate(dto)).isEmpty(); + } + + @Test + void nullDenied_fails_withDefaultMessage() { + var dto = new DtoNullableFalse(null); + Set> v = validator.validate(dto); + assertThat(v).hasSize(1); + + assertThat(v.iterator().next().getMessage()) + .isEqualTo("커스텀-불일치"); + } + + @Test + void match_passes() { + assertThat(validator.validate(new DtoNullableTrue("A"))).isEmpty(); + assertThat(validator.validate(new DtoNullableTrue("MUSICAL"))).isEmpty(); + } + + @Test + void mismatch_fails_withCustomViolation() { + Set> v = validator.validate(new DtoNullableTrue("musical")); + assertThat(v).hasSize(1); + assertThat(v.iterator().next().getMessage()).isEqualTo("커스텀-불일치"); + } + + @Test + void constantsIsNull_branch() throws Exception { + EnumRequestValidator validator = new EnumRequestValidator(); + + setField(validator, "clazz", String.class); + setField(validator, "message", "ENUM 값이 올바르지 않습니다."); + setField(validator, "nullable", false); + + ConstraintValidatorContext ctx = mock(ConstraintValidatorContext.class); + ConstraintValidatorContext.ConstraintViolationBuilder builder = + mock(ConstraintValidatorContext.ConstraintViolationBuilder.class); + + when(ctx.buildConstraintViolationWithTemplate("ENUM 값이 올바르지 않습니다.")) + .thenReturn(builder); + + boolean result = validator.isValid("ANY", ctx); + + assertThat(result).isFalse(); + verify(ctx).disableDefaultConstraintViolation(); + verify(ctx).buildConstraintViolationWithTemplate("ENUM 값이 올바르지 않습니다."); + verify(builder).addConstraintViolation(); + verifyNoMoreInteractions(ctx, builder); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + enum Sample {A, MUSICAL} + + static class DtoNullableTrue { + @EnumRequest(value = Sample.class, nullable = true, message = "커스텀-불일치") + String v; + + DtoNullableTrue(String v) { + this.v = v; + } + } + + static class DtoNullableFalse { + @EnumRequest(value = Sample.class, message = "커스텀-불일치") + String v; + + DtoNullableFalse(String v) { + this.v = v; + } + } +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java b/internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java new file mode 100644 index 0000000..55ed82e --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java @@ -0,0 +1,11 @@ +package org.mandarin.booking.adapter; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +public interface AuthorizationRequestMatcherConfigurer { + + void authorizeRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth + ); +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java index a277d6f..cbaf9ea 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; -import org.springframework.http.HttpMethod; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; @@ -29,23 +28,18 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } + @Bean @Order(1) public SecurityFilterChain apiChain(HttpSecurity http, AuthenticationProvider authenticationProvider, AuthenticationEntryPoint authenticationEntryPoint, - AccessDeniedHandler accessDeniedHandler) + AccessDeniedHandler accessDeniedHandler, + AuthorizationRequestMatcherConfigurer authorizationRequestMatcherConfigurer) throws Exception { http .securityMatcher("/api/**") - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST, "/api/member").permitAll() - .requestMatchers("/api/auth/login").permitAll() - .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers(HttpMethod.POST, "/api/show/schedule").hasAuthority("ROLE_DISTRIBUTOR") - .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") - .anyRequest().authenticated() - ) + .authorizeHttpRequests(authorizationRequestMatcherConfigurer::authorizeRequests) .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java new file mode 100644 index 0000000..6f5e4cb --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.adapter; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import org.jspecify.annotations.NullUnmarked; + +@NullUnmarked +public record SliceView( + @JsonProperty("contents") List contents, + @JsonProperty("page") int page, + @JsonProperty("size") int size, + @JsonProperty("hasNext") boolean hasNext +) { + public SliceView(List contents, int page, int size) { + this(contents, page, size, contents.size() > size); + } +}