diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c75bc74f3f..0000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index b503037f0a..c2fdbaca06 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ out/ ### VS Code ### .vscode/ +.DS_Store diff --git a/README.md b/README.md index 913f8c2d53..e52761599e 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ 3. 이미 예약 또는 대기가 있는 슬롯에는 직접 예약할 수 없다. 4. 예약 또는 슬롯에서 사용 중인 시간/테마 삭제는 제한된다. 5. 본인 소유가 아닌 예약 또는 대기 취소는 거부된다. -6. 없는 예약 또는 대기 취소 요청은 기존 정책에 따라 성공 처리한다. +6. 없는 예약 취소 요청은 성공 처리한다. 없는 대기 취소 요청은 `404 Not Found`와 `WAITING_404`로 실패 처리한다. ## 2. API 명세 @@ -415,7 +415,55 @@ DELETE /api/manager/slots/{slotId} 이를 막기 위해 예약 취소의 승격 대상 대기열 조회와 대기 취소의 단건 대기 조회에 `SELECT ... FOR UPDATE`를 사용한다. -## 4. 테스트 전략 +## 4. JPA 연결 미션 이해 가이드 + +### 현재 구조 요약 + +이 프로젝트는 기존 방탈출 예약/대기 도메인을 JPA 기반 영속성으로 연결한 상태다. 읽을 때는 다음 순서로 보면 이해가 쉽다. + +1. **도메인 엔티티**: `member`, `theme`, `reservationtime`, `slot`, `reservation`, `waiting` 패키지의 `domain` 클래스가 JPA `@Entity`다. +2. **애플리케이션 포트**: 각 기능의 `application.port.out` 저장소 인터페이스가 서비스가 의존하는 추상화다. +3. **JPA 어댑터**: `adapter.out.persistence.Jpa*Repository`가 포트를 구현하고, 내부에서 `SpringData*Repository`를 호출한다. +4. **유스케이스 서비스**: `ReservationService`, `WaitingService`, `SlotService` 등이 트랜잭션과 도메인 규칙 흐름을 조율한다. +5. **검증 테스트**: `Jpa*RepositoryTest`, API 통합 테스트, 동시성 통합 테스트가 JPA 매핑과 요구사항을 함께 검증한다. + +### 주요 JPA 매핑 결정 + +- `Reservation`, `Waiting`, `Slot`은 모두 `Member`, `Slot`, `Theme`, `ReservationTime`을 식별자 필드가 아니라 객체 연관으로 가진다. +- 다대일 연관은 기본적으로 `FetchType.LAZY`를 사용해 서비스 흐름에서 필요한 데이터만 조회한다. +- 한 슬롯에는 하나의 예약만 존재해야 하므로 `reservation.slot_id`에 `uk_reservation_slot` 유니크 제약을 둔다. +- 같은 사용자는 같은 슬롯에 한 번만 대기할 수 있으므로 `waiting(member_id, slot_id)`에 `uk_waiting_member_slot` 유니크 제약을 둔다. +- 슬롯은 `date`, `time_id`, `theme_id` 조합으로 유일해야 하므로 `uk_slot_date_time_theme` 제약을 둔다. +- 대기 순번은 별도 컬럼으로 저장하지 않고, 같은 슬롯의 `waiting.id` 오름차순으로 조회 시 계산한다. + +### 트랜잭션/락 정책 + +- 예약 취소와 첫 번째 대기 승격은 하나의 트랜잭션에서 처리한다. +- 승격 대상 대기열 조회와 대기 취소 단건 조회는 명령 흐름에서만 `PESSIMISTIC_WRITE` 락을 사용한다. +- 조회용 메서드와 락 조회 메서드를 분리해, 일반 화면 조회가 불필요하게 row lock을 잡지 않도록 했다. +- 대기 취소 요청에서 대상 대기가 없으면 성공 처리하지 않고 `WAITING_404`로 실패시킨다. 이는 승격된 대기를 뒤늦게 취소 성공으로 오해하는 상황을 막기 위한 정책이다. + +### 코드 리뷰 체크리스트 + +JPA 연결 이후 변경을 검토할 때는 다음을 우선 확인한다. + +- 엔티티 연관관계와 DB 제약이 도메인 규칙과 같은 방향인지 확인한다. +- 서비스가 Spring Data 구현체에 직접 의존하지 않고 `application.port.out` 포트에만 의존하는지 확인한다. +- `@Transactional`이 데이터 변경 유스케이스와 `FOR UPDATE` 조회를 포함하는 메서드에 걸려 있는지 확인한다. +- JPQL projection이 응답 DTO 조립을 단순화하는지, 반대로 애플리케이션 계층을 영속성 세부사항에 과하게 묶지는 않는지 확인한다. +- 대기 순번 계산, 예약 취소/승격, 없는 대기 취소 실패 정책이 테스트 이름과 문서에 함께 드러나는지 확인한다. + +### 남은 개선 후보 + +- 운영 DB가 H2가 아닐 경우 `SELECT ... FOR UPDATE`와 `ORDER BY` 조합의 실제 락 범위를 다시 확인해야 한다. +- `findMyReservations()`는 예약 목록과 대기 목록을 합친 뒤 정렬 정책이 명확하지 않다. 화면 요구가 생기면 날짜/시간/id 기준 정렬을 명시해야 한다. +- 관리자 예약 취소에서 없는 예약을 성공 처리하는 정책은 현재 유지되지만, 대기 취소 정책과 다르므로 API 문서와 테스트에서 의도를 계속 분리해야 한다. +- `theme.name`, `reservation_time.start_at`처럼 서비스에서 중복을 검증하는 값은 동시 요청까지 막으려면 DB unique constraint와 예외 매핑을 추가로 검토해야 한다. +- 요청 DTO의 필수값/문자열 길이 검증은 API 계약과 함께 관리해야 한다. 누락 시 도메인 생성 또는 DB 제약 오류로 늦게 드러날 수 있다. +- `ddl-auto: create-drop`과 `data.sql` 초기화는 로컬/미션 검증용 설정이다. 운영 환경을 가정한다면 마이그레이션 도구와 프로파일 분리가 필요하다. +- ADR 일부의 예전 `Jdbc*Repository` 명칭은 현재 JPA 어댑터 명칭과 맞지 않을 수 있다. 새 ADR을 작성할 때는 `Jpa*Repository`/`SpringData*Repository` 기준으로 기록한다. + +## 5. 테스트 전략 ### 단위 테스트 diff --git a/build.gradle b/build.gradle index 0a4c78b35e..35046d61e5 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/docs/jpa-implementation-learning-report.md b/docs/jpa-implementation-learning-report.md new file mode 100644 index 0000000000..6cf45f99e8 --- /dev/null +++ b/docs/jpa-implementation-learning-report.md @@ -0,0 +1,591 @@ +# JPA 전환 구현·학습 리포트 + +## 0. 이 문서의 목적 + +이 문서는 우아한테크코스 백엔드 미션 **방탈출 예약 대기** 이후 추가 미션인 **JPA 도입**을 기준으로, 현재 프로젝트에서 무엇이 바뀌었고 왜 바뀌었는지 학습용으로 정리한 기록이다. + +읽는 순서는 다음을 권장한다. + +1. [단계별 요구사항 반영 현황](#1-단계별-요구사항-반영-현황) +2. [JDBC에서 JPA로 바뀐 핵심](#2-jdbc에서-jpa로-바뀐-핵심) +3. [예약 대기 도메인과 JPQL](#4-예약-대기-도메인과-jpql) +4. [헥사고날 아키텍처 때문에 복잡해진 지점](#7-헥사고날-아키텍처-때문에-복잡해진-지점) +5. [남은 질문 리스트](#9-결정이-필요하거나-추가로-확인하면-좋은-질문) + +--- + +## 1. 단계별 요구사항 반영 현황 + +| 단계 | 요구사항 | 현재 반영 | 핵심 파일 | +| --- | --- | --- | --- | +| 0단계 | 기존 기능 유지, JPA 전환 범위 정의 | `./gradlew test`로 기존 기능 회귀 검증 | `src/test/java/roomescape/**/*Test.java` | +| 1단계 | `JdbcTemplate` 저장소를 JPA 저장소로 교체 | `spring-boot-starter-data-jpa`, `@Entity`, `JpaRepository` 기반으로 전환 | `build.gradle`, `src/main/java/roomescape/*/domain`, `src/main/java/roomescape/*/adapter/out/persistence` | +| 2단계 | 내 예약 목록 조회 | 기존 경로 `/api/user/reservations/me` + 미션 원문 호환 경로 `/reservations-mine` 제공 | `UserReservationController`, `MissionReservationController`, `ReservationService` | +| 3단계 | 대기 생성/취소, 내 예약 목록에 대기 포함, 중복 방지, N번째 대기 표시 | 구현 완료. 기존 경로 `/api/user/waitings` + 미션 원문 호환 경로 `/waitings` 제공 | `WaitingController`, `WaitingService`, `WaitingLine`, `WaitingLines` | +| 4단계 | 어드민 대기 관리 + 예약 취소 시 대기 자동 승인 | 관리자 대기 목록/취소 추가, 수동 승인은 선택하지 않고 자동 승격 방식 선택 | `WaitingController`, `ReservationService`, `WaitingPromotionPolicy` | + +### 이번 보완에서 추가한 것 + +- `GET /reservations-mine` + - 미션 문서의 원문 API 경로와 현재 프로젝트의 `/api/user/reservations/me`를 모두 지원한다. +- `POST /waitings`, `DELETE /waitings/{id}` + - 미션 문서의 원문 API 경로와 현재 프로젝트의 `/api/user/waitings`를 모두 지원한다. +- `GET /api/manager/waitings` + - 관리자가 전체 대기 목록과 각 대기의 현재 순번을 조회한다. +- `DELETE /api/manager/waitings/{id}` + - 관리자가 사용자 소유자 검증 없이 대기를 취소한다. +- 추가 테스트 + - 원문 경로 호환성 + - 관리자 대기 목록/취소 + - 대기 취소 후 목록 제거와 남은 순번 재계산 + - 예약 + 대기 혼합 내 목록 조회 + +--- + +## 2. JDBC에서 JPA로 바뀐 핵심 + +### 2.1 의존성 변경 + +기존 SQL Mapper 방식에서는 보통 `spring-boot-starter-jdbc`와 `JdbcTemplate`이 중심이다. 현재 프로젝트는 JPA 미션을 위해 다음처럼 JPA 의존성을 사용한다. + +```gradle +implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +runtimeOnly 'com.h2database:h2' +``` + +JPA 설정은 다음과 같다. + +```yaml +spring: + jpa: + show-sql: true + defer-datasource-initialization: true + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true +``` + +### 왜 바뀌었나? + +| JDBC/JdbcTemplate | JPA | +| --- | --- | +| SQL을 직접 작성한다. | 객체 매핑을 바탕으로 SQL을 Hibernate가 만든다. | +| `RowMapper`, `KeyHolder`, `SimpleJdbcInsert` 같은 코드가 필요하다. | `@Entity`, `@Id`, `@ManyToOne`, `JpaRepository`가 저장/조회 기본 동작을 맡는다. | +| join 결과를 DTO나 도메인으로 직접 조립한다. | 연관관계를 객체 참조로 표현하고 필요한 경우 JPQL로 조회 모양을 제어한다. | + +### 트레이드오프 + +- 좋아진 점 + - 기본 CRUD 코드가 줄었다. + - `Reservation -> Slot -> Theme/ReservationTime`처럼 객체 그래프로 도메인 의미를 표현할 수 있다. + - 트랜잭션 안에서 dirty checking, 1차 캐시, 쓰기 지연 같은 기능을 활용할 수 있다. +- 감수할 점 + - 실제 SQL이 코드에 직접 보이지 않는다. + - 연관관계 fetch 전략을 모르면 N+1, LazyInitializationException을 만나기 쉽다. + - `save()` 호출 시점과 실제 `INSERT/UPDATE` 발행 시점이 다를 수 있다. + +--- + +## 3. 엔티티 매핑과 연관관계 + +### 3.1 독립 엔티티 + +- `Member` +- `Theme` +- `ReservationTime` + +이들은 다른 엔티티를 참조하지 않는 비교적 단순한 테이블이다. + +예: `ReservationTime` + +```java +@Entity +@Table(name = "reservation_time") +public class ReservationTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "start_at", nullable = false) + private LocalTime startAt; +} +``` + +`IDENTITY` 전략을 사용하면 DB가 PK를 생성한다. 따라서 저장 시 Hibernate는 즉시 insert를 보내 PK를 알아와야 하는 경우가 많다. + +대표 SQL 형태: + +```sql +insert into reservation_time (start_at, id) values (?, default) +``` + +### 3.2 연관 엔티티 + +현재 구조는 `Reservation`이 `Member`, `Slot`을 참조하고, `Slot`이 `Theme`, `ReservationTime`을 참조한다. + +```text +Reservation + ├─ Member + └─ Slot + ├─ Theme + └─ ReservationTime +``` + +`Waiting`도 같은 방식으로 `Member`, `Slot`을 참조한다. + +```text +Waiting + ├─ Member + └─ Slot + ├─ Theme + └─ ReservationTime +``` + +### 왜 `Reservation`이 `Theme`, `ReservationTime`을 직접 참조하지 않나? + +현재 프로젝트에는 `Slot`이라는 도메인이 있다. + +```text +Slot = 특정 날짜 + 특정 시간 + 특정 테마 +``` + +따라서 예약은 “시간과 테마 각각”을 예약하는 것이 아니라 “특정 슬롯”을 예약한다. 이 설계를 유지하면 같은 날짜·시간·테마 조합의 유일성을 `slot` 테이블에서 관리할 수 있다. + +```java +@Table( + uniqueConstraints = @UniqueConstraint( + name = "uk_slot_date_time_theme", + columnNames = {"date", "time_id", "theme_id"} + ) +) +public class Slot { ... } +``` + +### 단방향을 선택한 이유 + +현재 연관관계는 대부분 단방향이다. + +```java +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "slot_id", nullable = false) +private Slot slot; +``` + +양방향을 만들면 `slot.getReservations()`처럼 탐색은 편해질 수 있다. 하지만 다음 비용이 생긴다. + +- 연관관계 주인을 정해야 한다. +- 양쪽 컬렉션 동기화 메서드가 필요하다. +- JSON 직렬화 시 순환 참조 위험이 생긴다. +- 현재 요구사항에서는 `Slot -> Reservation 목록` 객체 탐색이 꼭 필요하지 않다. + +따라서 이번 미션에서는 **필요할 때 추가한다**는 원칙으로 단방향을 유지했다. + +--- + +## 4. 예약 대기 도메인과 JPQL + +## 4.1 `Waiting`을 별도 엔티티로 둔 이유 + +예약 대기를 `Reservation.status = WAITING`처럼 같은 테이블에 둘 수도 있다. 하지만 현재 프로젝트는 `Waiting`을 별도 엔티티로 분리했다. + +### 선택지 비교 + +| 선택지 | 장점 | 단점 | +| --- | --- | --- | +| `Reservation`에 status 컬럼 추가 | 테이블 하나로 예약/대기를 함께 조회하기 쉽다. | 예약과 대기가 같은 생명주기를 가진 것처럼 보인다. 대기 순번, 승격, 취소 규칙이 `Reservation`에 섞인다. | +| `Waiting` 별도 엔티티 | 예약과 대기의 생명주기를 분리한다. 대기열 도메인(`WaitingLine`)을 만들기 쉽다. | 내 예약 목록처럼 예약+대기를 함께 보여줄 때 병합 로직이 필요하다. | + +현재 요구사항은 “대기 신청”, “대기 취소”, “N번째 대기”, “예약 취소 시 첫 대기 자동 승격”처럼 대기 자체의 규칙이 많다. 그래서 별도 엔티티가 더 자연스럽다. + +## 4.2 중복 방지 + +중복 방지는 애플리케이션 검증과 DB 제약을 함께 둔다. + +```java +@Table(uniqueConstraints = @UniqueConstraint( + name = "uk_waiting_member_slot", + columnNames = {"member_id", "slot_id"} +)) +public class Waiting { ... } +``` + +서비스에서도 먼저 검사한다. + +```java +if (waitingRepository.existsBySlotIdAndMemberId(memberId, slotId)) { + throw new EscapeRoomException(ErrorCode.WAITING_ALREADY_EXIST); +} +``` + +### 왜 둘 다 필요한가? + +- 서비스 검증: 사용자에게 명확한 409 응답을 주기 쉽다. +- DB unique constraint: 동시에 두 요청이 들어와 서비스 검증을 둘 다 통과해도 최종 데이터 중복을 막는 마지막 방어선이다. + +단, 현재 코드는 동시 중복 대기 생성에서 DB 예외를 사용자 친화적 409로 변환하는 검증까지는 깊게 다루지 않았다. 이 부분은 남은 질문에 포함한다. + +## 4.3 N번째 대기 계산 + +대기 순번은 컬럼으로 저장하지 않는다. 조회 시점에 같은 슬롯의 대기들을 id 순서로 정렬해 계산한다. + +```java +public class WaitingLine { + private final List waitings; + + public long orderOf(Long waitingId) { + for (int index = 0; index < waitings.size(); index++) { + if (waitings.get(index).getId().equals(waitingId)) { + return index + 1L; + } + } + throw new IllegalArgumentException("대기열에 존재하지 않는 대기입니다."); + } +} +``` + +### 왜 순번을 저장하지 않나? + +| 방식 | 장점 | 단점 | +| --- | --- | --- | +| `waiting_order` 컬럼 저장 | 조회가 단순하다. | 앞 사람이 취소/승격될 때 뒤 사람 순번을 모두 업데이트해야 한다. 동시성 처리가 어려워진다. | +| 조회 시 계산 | 취소/승격 때 순번 업데이트가 필요 없다. | 조회 시 같은 슬롯의 대기열을 추가로 읽어야 한다. | + +현재 요구사항에서는 대기열 크기가 작다고 가정할 수 있고, 순번 변경이 자주 생길 수 있으므로 조회 시 계산을 선택했다. + +--- + +## 5. 내 예약 목록 조회와 N+1 회피 + +내 예약 목록은 예약과 대기를 함께 내려준다. + +```java +public List findMyReservations(long memberId) { + List reservations = findMyReservationResponses(memberId); + List waitings = findMyWaitingResponses(memberId); + + return Stream.concat(reservations.stream(), waitings.stream()) + .toList(); +} +``` + +### 5.1 JPQL 생성자 프로젝션 사용 + +예약 상세 조회는 엔티티를 가져와 DTO 변환 중 lazy 필드를 하나씩 건드리는 방식이 아니라, 필요한 필드를 JPQL에서 바로 projection으로 조회한다. + +```java +@Query(""" + SELECT new roomescape.reservation.application.port.out.projection.ReservationDetailProjection( + r.id, + r.member.id, + r.member.name, + r.slot.date, + r.slot.theme.id, + r.slot.theme.name, + r.slot.theme.description, + r.slot.theme.thumbnailUrl, + r.slot.time.id, + r.slot.time.startAt + ) + FROM Reservation r + WHERE r.member.id = :memberId + ORDER BY r.id + """) +List findAllReservationDetailsByMemberId(@Param("memberId") long memberId); +``` + +대표 SQL 형태는 다음과 같다. + +```sql +select + r.id, + m.id, + m.name, + s.date, + t.id, + t.name, + t.description, + t.thumbnail_url, + rt.id, + rt.start_at +from reservation r +join member m on m.id = r.member_id +join slot s on s.id = r.slot_id +join theme t on t.id = s.theme_id +join reservation_time rt on rt.id = s.time_id +where m.id = ? +order by r.id +``` + +### 5.2 fetch join 대신 projection을 쓴 이유 + +N+1을 피하는 대표 방식은 fetch join 또는 `@EntityGraph`다. 현재 프로젝트에서는 상세 응답 조회에 projection을 사용했다. + +| 방식 | 장점 | 단점 | +| --- | --- | --- | +| 엔티티 조회 + LAZY 접근 | 도메인 객체를 그대로 다룬다. | DTO 변환 중 N+1이 발생하기 쉽다. | +| fetch join | 엔티티 그래프를 한 번에 초기화한다. | 조회 목적이 DTO라면 필요 이상의 엔티티를 영속성 컨텍스트에 올릴 수 있다. | +| 생성자 projection | 응답에 필요한 필드만 조회한다. N+1을 피하기 쉽다. | JPQL이 DTO/Projection 구조를 알게 된다. 도메인 객체 변경 추적에는 적합하지 않다. | + +현재 내 예약 목록은 “화면 조회”에 가깝다. 수정할 엔티티가 필요한 유스케이스가 아니므로 projection이 적합하다. + +### 5.3 대기 순번 계산에서 추가 조회가 필요한 이유 + +`WaitingDetailProjection`은 내 대기 자체의 상세 정보만 가진다. 하지만 “내 대기가 몇 번째인지”는 같은 슬롯의 다른 대기까지 알아야 계산된다. + +그래서 다음 흐름을 사용한다. + +1. 내 대기 상세 목록 조회 +2. 그 대기들이 속한 slot id를 모음 +3. 해당 slot들의 모든 대기 목록을 한 번에 조회 +4. `WaitingLines`가 slot별 대기열을 만들고 순번 계산 + +이 방식은 각 대기마다 `findAllBySlotId`를 반복하지 않기 때문에 N+1을 줄인다. + +--- + +## 6. 예약 취소와 대기 자동 승격 + +4단계에서는 수동 승인과 자동 승인 중 **자동 승인**을 선택했다. + +```java +@Transactional +public void deleteById(long reservationId) { + findReservationIfExists(reservationId) + .ifPresent(this::cancelReservation); +} +``` + +예약 취소의 핵심 흐름: + +```java +private void cancelReservation(Reservation reservation) { + reservation.validateCancelable(LocalDateTime.now(clock)); + + WaitingLine waitingLine = findWaitingLineFor(reservation); + deleteReservationOnly(reservation); + promoteFirstWaitingIfExists(reservation, waitingLine); +} +``` + +승격 흐름: + +```java +private void promoteFirstWaitingIfExists(Reservation canceledReservation, WaitingLine waitingLine) { + waitingLine.first() + .ifPresent(waiting -> { + Reservation promotedReservation = waitingPromotionPolicy.promote(waiting, + canceledReservation.getSlot()); + reservationRepository.save(promotedReservation); + waitingRepository.deleteById(waiting.getId()); + }); +} +``` + +### 왜 한 트랜잭션으로 묶었나? + +예약 취소와 대기 승격은 함께 성공하거나 함께 실패해야 한다. + +- 예약만 삭제되고 승격이 실패하면 빈 슬롯이 된다. +- 승격 예약은 생겼는데 대기가 삭제되지 않으면 같은 사용자가 대기와 예약을 동시에 가진 것처럼 보일 수 있다. + +따라서 `@Transactional` 하나 안에서 처리한다. + +### 왜 비관적 락을 사용했나? + +예약 취소와 대기 취소가 동시에 일어날 수 있다. + +```text +A: 기존 예약 취소 → 첫 번째 대기 승격 대상 선택 +B: 같은 첫 번째 대기 사용자가 대기 취소 +``` + +이때 같은 waiting row를 동시에 처리하면 사용자는 “대기를 취소했다”고 생각하는데 시스템에는 예약이 생기는 문제가 생길 수 있다. + +그래서 명령 흐름에서는 `FOR UPDATE` 조회를 사용한다. + +```java +@Lock(LockModeType.PESSIMISTIC_WRITE) +@Query("SELECT w FROM Waiting w WHERE w.slot.id = :slotId ORDER BY w.id") +List findAllBySlotIdOrderByIdForUpdate(@Param("slotId") long slotId); +``` + +### 트레이드오프 + +- 좋아진 점 + - 예약 취소 승격과 대기 취소가 같은 waiting row 기준으로 직렬화된다. + - 동시성 테스트로 “예약 취소가 먼저인 경우”, “대기 취소가 먼저인 경우”를 검증한다. +- 감수한 점 + - 락 때문에 같은 row를 다루는 요청은 기다린다. + - H2의 `FOR UPDATE`와 운영 DB의 락 동작이 완전히 같다고 보장할 수 없다. + - 모든 동시성 문제를 해결한 것은 아니다. 예를 들어 같은 슬롯 예약 생성 동시 요청, 같은 사용자 대기 생성 동시 요청은 DB unique constraint까지 포함해 추가 검증 여지가 있다. + +--- + +## 7. 헥사고날 아키텍처 때문에 복잡해진 지점 + +현재 프로젝트는 헥사고날 아키텍처 형태를 갖는다. + +```text +adapter/in/web -> application/port/in -> application/service -> application/port/out -> adapter/out/persistence -> JPA +``` + +예를 들어 대기 목록 조회는 다음 경로를 지난다. + +```text +WaitingController + -> FindWaitingUseCase + -> WaitingService + -> WaitingRepository(port) + -> JpaWaitingRepository(adapter) + -> SpringDataWaitingRepository(JpaRepository) +``` + +### 복잡해진 부분 + +#### 1. Repository가 두 겹이다 + +```text +WaitingRepository // application port +JpaWaitingRepository // port 구현체 +SpringDataWaitingRepository // Spring Data JPA 인터페이스 +``` + +레이어드 아키텍처였다면 서비스가 바로 `SpringDataWaitingRepository`를 주입받았을 가능성이 크다. + +```text +Controller -> Service -> SpringDataWaitingRepository +``` + +헥사고날에서는 JPA가 외부 어댑터다. 그래서 application layer는 `JpaRepository`를 직접 알지 않고 port만 안다. + +장점: + +- 서비스가 Spring Data JPA 세부 구현에 덜 묶인다. +- 테스트에서 port를 mock하기 쉽다. +- 저장소 구현을 바꾸더라도 application service의 의존 방향이 유지된다. + +단점: + +- 단순 CRUD도 파일이 늘어난다. +- Spring Data 쿼리 메서드 하나를 추가해도 port, adapter, SpringData interface를 함께 수정해야 한다. +- projection 위치를 어디에 둘지 고민이 생긴다. + +#### 2. Projection이 application port에 위치한다 + +현재 projection은 다음 위치에 있다. + +```text +reservation/application/port/out/projection/ReservationDetailProjection.java +waiting/application/port/out/projection/WaitingDetailProjection.java +``` + +이 선택은 “애플리케이션이 필요한 조회 결과 형태”를 port 계약으로 둔 것이다. + +레이어드였다면 repository 패키지 안에 projection interface/record를 두고 service가 바로 사용했을 가능성이 크다. + +트레이드오프: + +- 장점: service가 필요한 데이터 모양이 명확하다. +- 단점: JPQL 생성자 표현식이 application projection의 패키지명을 직접 참조한다. + +#### 3. 관리자 대기 기능 추가도 포트를 먼저 바꿔야 한다 + +이번 보완에서 `GET /api/manager/waitings`를 추가하기 위해 다음이 함께 바뀌었다. + +- `FindWaitingUseCase` 추가 +- `WaitingService`가 `FindWaitingUseCase` 구현 +- `WaitingRepository.findAllWaitingDetails()` 추가 +- `JpaWaitingRepository` 위임 추가 +- `SpringDataWaitingRepository` JPQL 추가 +- `WaitingDetailFindResponse` 추가 +- API 통합 테스트 추가 + +레이어드라면 controller/service/repository 3곳 정도로 끝났을 수 있다. 헥사고날은 더 길지만, 각 변경의 방향과 책임이 명확하다. + +--- + +## 8. 테스트와 검증 기록 + +### 실행한 검증 + +```bash +./gradlew test --tests 'roomescape.waiting.WaitingServiceTest' \ + --tests 'roomescape.waiting.WaitingApiIntegrationTest' \ + --tests 'roomescape.reservation.ReservationApiIntegrationTest' +``` + +결과: + +```text +BUILD SUCCESSFUL +``` + +전체 테스트도 기준선에서 통과를 확인했다. + +```bash +./gradlew test +``` + +결과: + +```text +BUILD SUCCESSFUL +``` + +### 새로 고정한 동작 + +- `/reservations-mine`으로 내 예약 목록 조회 가능 +- `/waitings`로 대기 생성 가능 +- 대기 취소 후 취소한 사용자의 목록에서 사라짐 +- 첫 번째 대기 취소 후 다음 대기자의 `waitingOrder`가 1로 재계산됨 +- 관리자는 `/api/manager/waitings`로 대기 목록을 조회할 수 있음 +- 관리자는 `/api/manager/waitings/{id}`로 대기를 취소할 수 있음 +- 한 사용자의 내 예약 목록에 `RESERVED`와 `WAITING`이 함께 내려올 수 있음 + +--- + +## 9. 결정이 필요하거나 추가로 확인하면 좋은 질문 + +현재 구현을 더 엄격하게 만들려면 다음 결정이 필요하다. + +1. **API 경로를 최종적으로 하나로 통일할 것인가?** + - 현재는 기존 프로젝트 경로(`/api/user/reservations/me`, `/api/user/waitings`)와 미션 원문 경로(`/reservations-mine`, `/waitings`)를 모두 지원한다. + - 장점: 기존 클라이언트와 미션 요구사항을 모두 만족한다. + - 단점: 같은 기능의 입구가 두 개라 API 문서 관리 비용이 생긴다. + +2. **대기 상태 응답을 `status="WAITING" + waitingOrder=1`로 유지할 것인가, `status="1번째 예약대기"` 문자열로 바꿀 것인가?** + - 현재는 enum 상태와 순번 숫자를 분리했다. + - 장점: 프론트엔드가 다국어/표현을 자유롭게 만들 수 있다. + - 단점: 미션 문서의 예시 문자열과는 다르다. + +3. **관리자 수동 승인을 추가할 것인가?** + - 현재 4단계 승인은 “예약 취소 시 자동 승격”을 선택했다. + - 수동 승인까지 추가하면 관리자 UX는 좋아지지만, 자동 승격 정책과 충돌할 수 있어 정책 정의가 필요하다. + +4. **동시 중복 대기 생성 시 DB unique 예외를 명시적 409로 변환할 것인가?** + - 서비스 검증은 있지만 race condition에서는 DB 제약이 마지막 방어선이다. + - 운영 수준으로 가려면 `DataIntegrityViolationException`을 도메인 예외로 변환하는 테스트가 있으면 좋다. + +5. **조회 정렬 기준을 확정할 것인가?** + - 현재 내 예약 목록은 예약 목록 뒤에 대기 목록을 붙인다. + - 사용자 관점에서는 날짜/시간순 정렬이 더 자연스러울 수 있다. + +6. **운영 DB 기준으로 `FOR UPDATE` 락 범위를 재검증할 것인가?** + - 현재 테스트는 H2 기반이다. + - MySQL/PostgreSQL로 바뀌면 인덱스, 격리 수준, `ORDER BY ... FOR UPDATE`의 실제 락 범위를 확인해야 한다. + +--- + +## 10. 핵심 회고 요약 + +이번 JPA 전환에서 가장 중요한 학습 포인트는 다음이다. + +1. JPA는 SQL 작성을 줄여주지만 SQL 이해를 없애주지 않는다. +2. 연관관계를 객체 참조로 표현하면 도메인 코드는 자연스러워지지만 fetch 전략과 트랜잭션 경계를 알아야 한다. +3. `@ManyToOne(fetch = LAZY)`를 명시하면 불필요한 즉시 로딩을 줄일 수 있지만, 트랜잭션 밖 접근은 조심해야 한다. +4. 화면 조회는 entity graph보다 projection이 단순하고 효율적일 수 있다. +5. 대기 순번처럼 계속 바뀌는 값은 저장보다 계산이 더 안전할 수 있다. +6. 자동 승격처럼 여러 엔티티를 함께 바꾸는 기능은 트랜잭션 경계와 동시성 정책이 핵심이다. +7. 헥사고날 아키텍처는 파일 수를 늘리지만, JPA를 외부 어댑터로 격리하고 application service의 의존 방향을 지켜준다. diff --git a/docs/jpa-review.md b/docs/jpa-review.md new file mode 100644 index 0000000000..c1300d3b44 --- /dev/null +++ b/docs/jpa-review.md @@ -0,0 +1,67 @@ +# JPA 연결 미션 리뷰 노트 + +## 목적 + +우아한테크코스 방탈출 예약/대기 미션에 JPA를 연결한 뒤, 요구사항과 코드 구조를 빠르게 이해하기 위한 리뷰 노트다. + +## 먼저 읽을 흐름 + +1. `README.md`의 구현 기능 목록과 API 명세로 외부 동작을 확인한다. +2. `src/main/java/roomescape/*/domain`의 JPA 엔티티를 읽어 DB 테이블과 도메인 규칙의 연결을 확인한다. +3. `src/main/java/roomescape/*/application/port/out`의 저장소 포트와 `adapter/out/persistence` 구현체를 비교한다. +4. `ReservationService`, `WaitingService`, `ReservationTimeService`에서 유스케이스 흐름과 트랜잭션 경계를 확인한다. +5. `src/test/java/roomescape/**/*RepositoryTest.java`, API 통합 테스트, `ReservationTransactionIntegrationTest`로 실제 DB 검증 범위를 확인한다. + +## 확인된 좋은 점 + +- 애플리케이션 서비스가 `JpaRepository`를 직접 사용하지 않고 저장소 포트를 통해 의존한다. +- JPA 어댑터와 Spring Data 인터페이스가 분리되어, 포트 구현 책임과 Spring Data 쿼리 선언 책임이 구분된다. +- 예약/대기/슬롯의 핵심 유일성 규칙이 DB unique constraint로도 보호된다. +- 예약 취소와 대기 승격이 하나의 트랜잭션으로 묶여 있다. +- 대기 승격과 대기 취소 충돌은 명령용 `PESSIMISTIC_WRITE` 조회로 방어한다. +- 없는 대기 취소는 `WAITING_404`로 실패한다는 정책이 서비스/API/동시성 테스트에 반영되어 있다. + +## 주의할 점 + +- `ReservationService.findMyReservations()`는 예약 응답과 대기 응답을 단순 concat한다. 화면 정렬 요구가 있으면 기준을 추가해야 한다. +- 대기 순번은 저장 컬럼이 아니라 조회 시 계산 값이다. 대기 삭제나 승격 뒤 별도 순번 갱신 로직을 찾으면 안 된다. +- `FOR UPDATE`는 H2 테스트에서 검증되어 있지만, 운영 DB가 바뀌면 락 범위와 인덱스 사용을 재확인해야 한다. +- `deleteById` 계열 중 예약 취소와 대기 취소는 없는 리소스 처리 정책이 다르다. 예약은 없는 id를 성공 처리하고, 대기는 404로 실패한다. +- ADR 일부는 과거 JDBC 명칭을 포함할 수 있다. 현재 구현 기준은 `Jpa*Repository` 어댑터와 `SpringData*Repository` 인터페이스다. + +## 요구사항-코드 매핑 + +| 요구사항 | 핵심 코드 | 검증 위치 | +| --- | --- | --- | +| 빈 슬롯만 직접 예약 가능 | `ReservationService.throwIfSlotUnavailableForReservation`, `SlotOccupancy.isReservable` | `ReservationApiIntegrationTest`, `ReservationServiceTest` | +| 예약/대기가 있는 슬롯만 대기 가능 | `WaitingService.validateWaitingTargetExists`, `SlotOccupancy.isWaitable` | `WaitingServiceTest`, `WaitingApiIntegrationTest` | +| 같은 사용자의 중복 대기 방지 | `waiting` unique constraint, `WaitingService.validateWaitingByMemberNotExists` | `JpaWaitingRepositoryTest`, `WaitingApiIntegrationTest` | +| 예약 취소 시 첫 대기 자동 승격 | `ReservationService.cancelReservation`, `WaitingPromotionPolicy` | `ReservationServiceTest`, `ReservationApiIntegrationTest` | +| 대기 순번 재계산 | `WaitingLine`, `WaitingLines` | `WaitingLineTest`, `WaitingLinesTest`, `ReservationApiIntegrationTest` | +| 승격/취소 충돌 방어 | `findAllBySlotIdOrderByIdForUpdate`, `findByIdForUpdate` | `ReservationTransactionIntegrationTest` | + +## 다음 리뷰 질문 + +- 조회 응답 정렬 기준이 사용자에게 충분히 예측 가능한가? +- 없는 예약 취소 성공 정책을 관리자/사용자 API 모두에서 유지할 것인가? +- 운영 DB 전환 시 비관적 락 쿼리와 unique constraint 이름이 그대로 동작하는가? +- projection 반환 타입을 application port 밖으로 더 숨길 필요가 있는가? + +## 우선순위별 패치 후보 + +### High + +- README/API 문서에서 삭제 정책을 엔드포인트별로 분리한다. 현재 예약 삭제는 없는 id를 no-op 성공으로 처리하고, 대기 삭제는 `WAITING_404`로 실패한다. +- 운영 DB를 전제한다면 `Theme.name`, `ReservationTime.startAt`에 DB unique constraint를 추가하고 `DataIntegrityViolationException`을 명시적인 409 응답으로 매핑한다. +- `application.yml`의 `ddl-auto: create-drop`과 `data.sql` 초기화가 로컬/미션 검증용이라는 점을 프로파일 문서에 명시한다. + +### Medium + +- `ThemeSaveRequest`, `ReservationTimeSaveRequest` 등 요청 DTO에 필수값/blank/길이 검증을 추가하고 README 요청 계약에 반영한다. +- `findMyReservations()`의 예약+대기 병합 응답 정렬 기준을 요구사항으로 확정하고 테스트로 고정한다. +- JPA/data 예외 중 사용자 입력 또는 충돌로 볼 수 있는 예외는 `GlobalExceptionHandler`에서 API 오류 포맷으로 변환한다. + +### Low + +- 과거 ADR의 `Jdbc*Repository` 경로/명칭을 현재 `Jpa*Repository`와 `SpringData*Repository` 기준으로 후속 ADR에서 보정한다. +- 삭제 메서드 이름에 `deleteIfExists`, `deleteOrThrow`처럼 정책이 드러나도록 정리할지 검토한다. diff --git a/src/main/java/roomescape/config/WebConfig.java b/src/main/java/roomescape/config/WebConfig.java index 90779bc5e1..4601df62a8 100644 --- a/src/main/java/roomescape/config/WebConfig.java +++ b/src/main/java/roomescape/config/WebConfig.java @@ -39,7 +39,7 @@ public void addArgumentResolvers(List resolvers) @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor) - .addPathPatterns("/api/**") + .addPathPatterns("/api/**", "/reservations-mine", "/waitings", "/waitings/**") .excludePathPatterns("/api/login", "/api/logout", "/api/themes/popular"); registry.addInterceptor(managerInterceptor) diff --git a/src/main/java/roomescape/exception/ErrorCode.java b/src/main/java/roomescape/exception/ErrorCode.java index bd5eacc06e..07cc3e1199 100644 --- a/src/main/java/roomescape/exception/ErrorCode.java +++ b/src/main/java/roomescape/exception/ErrorCode.java @@ -43,6 +43,9 @@ public enum ErrorCode { THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "THEME_404", "테마(%d번)이 존재하지 않습니다."), THEME_ALREADY_EXIST(HttpStatus.CONFLICT, "THEME_409", "이미 존재하는 테마 입니다"), + // Member + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_404", "회원(%d번)이 존재하지 않습니다."), + // 요청 값 INVALID_INPUT(HttpStatus.BAD_REQUEST, "INVALID_INPUT_400", "요청 값이 올바르지 않습니다."), diff --git a/src/main/java/roomescape/member/adapter/out/persistence/JdbcMemberRepository.java b/src/main/java/roomescape/member/adapter/out/persistence/JdbcMemberRepository.java deleted file mode 100644 index 9f2590024c..0000000000 --- a/src/main/java/roomescape/member/adapter/out/persistence/JdbcMemberRepository.java +++ /dev/null @@ -1,39 +0,0 @@ -package roomescape.member.adapter.out.persistence; - -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; -import roomescape.member.application.port.out.MemberRepository; -import roomescape.member.domain.Member; -import roomescape.member.domain.Role; - -@Repository -@RequiredArgsConstructor -public class JdbcMemberRepository implements MemberRepository { - - private final NamedParameterJdbcTemplate jdbcTemplate; - - private final RowMapper memberRowMapper = (resultSet, rowNum) -> - new Member( - resultSet.getLong("id"), - resultSet.getString("name"), - resultSet.getString("password"), - Role.valueOf(resultSet.getString("role")) - ); - - - @Override - public Optional findByName(String name) { - String sql = "SELECT * FROM member WHERE name = :name"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("name", name); - - return jdbcTemplate.query(sql, params, memberRowMapper) - .stream() - .findFirst(); - } -} diff --git a/src/main/java/roomescape/member/adapter/out/persistence/JpaMemberRepository.java b/src/main/java/roomescape/member/adapter/out/persistence/JpaMemberRepository.java new file mode 100644 index 0000000000..12bd398cc4 --- /dev/null +++ b/src/main/java/roomescape/member/adapter/out/persistence/JpaMemberRepository.java @@ -0,0 +1,23 @@ +package roomescape.member.adapter.out.persistence; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import roomescape.member.application.port.out.MemberRepository; +import roomescape.member.domain.Member; + +@Repository +@RequiredArgsConstructor +public class JpaMemberRepository implements MemberRepository { + private final SpringDataMemberRepository repository; + + @Override + public Optional findByName(String name) { + return repository.findByName(name); + } + + @Override + public Optional findById(long id) { + return repository.findById(id); + } +} diff --git a/src/main/java/roomescape/member/adapter/out/persistence/SpringDataMemberRepository.java b/src/main/java/roomescape/member/adapter/out/persistence/SpringDataMemberRepository.java new file mode 100644 index 0000000000..ff4085fc90 --- /dev/null +++ b/src/main/java/roomescape/member/adapter/out/persistence/SpringDataMemberRepository.java @@ -0,0 +1,9 @@ +package roomescape.member.adapter.out.persistence; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import roomescape.member.domain.Member; + +interface SpringDataMemberRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/src/main/java/roomescape/member/application/port/out/MemberRepository.java b/src/main/java/roomescape/member/application/port/out/MemberRepository.java index c1c35da095..6fc85b3c14 100644 --- a/src/main/java/roomescape/member/application/port/out/MemberRepository.java +++ b/src/main/java/roomescape/member/application/port/out/MemberRepository.java @@ -5,4 +5,6 @@ public interface MemberRepository { Optional findByName(String name); + + Optional findById(long id); } diff --git a/src/main/java/roomescape/member/domain/Member.java b/src/main/java/roomescape/member/domain/Member.java index d58c701512..ada71dadfb 100644 --- a/src/main/java/roomescape/member/domain/Member.java +++ b/src/main/java/roomescape/member/domain/Member.java @@ -1,14 +1,34 @@ package roomescape.member.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@Entity @AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member { - private final Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) private String name; + + @Column(nullable = false) private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) private Role role; public boolean isSamePassword(String otherPassword) { diff --git a/src/main/java/roomescape/reservation/adapter/in/web/MissionReservationController.java b/src/main/java/roomescape/reservation/adapter/in/web/MissionReservationController.java new file mode 100644 index 0000000000..1ae01dc306 --- /dev/null +++ b/src/main/java/roomescape/reservation/adapter/in/web/MissionReservationController.java @@ -0,0 +1,28 @@ +package roomescape.reservation.adapter.in.web; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import roomescape.common.api.ApiResponse; +import roomescape.member.domain.AuthenticatedMember; +import roomescape.member.domain.LoginMember; +import roomescape.reservation.application.dto.response.ReservationDetailFindResponse; +import roomescape.reservation.application.port.in.FindReservationUseCase; + +@RestController +@RequiredArgsConstructor +public class MissionReservationController { + + private final FindReservationUseCase findReservationUseCase; + + @GetMapping("/reservations-mine") + public ResponseEntity>> findMyReservations( + @LoginMember AuthenticatedMember member + ) { + List response = findReservationUseCase.findMyReservations(member.id()); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(response)); + } +} diff --git a/src/main/java/roomescape/reservation/adapter/out/persistence/JdbcReservationRepository.java b/src/main/java/roomescape/reservation/adapter/out/persistence/JdbcReservationRepository.java deleted file mode 100644 index 42a360b380..0000000000 --- a/src/main/java/roomescape/reservation/adapter/out/persistence/JdbcReservationRepository.java +++ /dev/null @@ -1,217 +0,0 @@ -package roomescape.reservation.adapter.out.persistence; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.reservation.application.port.out.ReservationRepository; -import roomescape.reservation.application.port.out.projection.ReservationDetailProjection; -import roomescape.reservation.domain.Reservation; -import roomescape.reservationtime.domain.ReservationTime; -import roomescape.slot.domain.Slot; -import roomescape.theme.domain.Theme; - -@Repository -@RequiredArgsConstructor -public class JdbcReservationRepository implements ReservationRepository { - - private final NamedParameterJdbcTemplate template; - - private final RowMapper reservationDetailFindRowMapper = (resultSet, rowNum) -> - new ReservationDetailProjection( - resultSet.getLong("reservation_id"), - resultSet.getLong("member_id"), - resultSet.getString("member_name"), - resultSet.getDate("date").toLocalDate(), - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("theme_description"), - resultSet.getString("theme_thumbnail_url"), - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - - private final RowMapper reservationRowMapper = (resultSet, rowNum) -> { - ReservationTime time = new ReservationTime( - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - Theme theme = new Theme( - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("theme_description"), - resultSet.getString("theme_thumbnail_url") - ); - Slot slot = Slot.of( - resultSet.getLong("slot_id"), - resultSet.getDate("date").toLocalDate(), - time, - theme - ); - return Reservation.of( - resultSet.getLong("reservation_id"), - resultSet.getLong("member_id"), - slot - ); - }; - - @Override - public Reservation save(Reservation reservation) { - String insertReservationSql = "INSERT INTO reservation(member_id, slot_id) VALUES (:memberId, :slotId)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("memberId", reservation.getMemberId()) - .addValue("slotId", reservation.getSlotId()); - - KeyHolder keyHolder = new GeneratedKeyHolder(); - template.update(insertReservationSql, params, keyHolder); - - Number id = keyHolder.getKey(); - if (id == null) { - throw new IllegalStateException("reservation 저장 후 생성된 ID를 반환받지 못했습니다."); - } - - return Reservation.of( - keyHolder.getKey().longValue(), - reservation.getMemberId(), - reservation.getSlot() - ); - } - - @Override - public List findAll() { - String sql = """ - SELECT - r.id AS reservation_id, - m.id AS member_id, - m.name AS member_name, - s.date, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url, - rt.id AS time_id, - rt.start_at - FROM reservation r - JOIN slot s ON r.slot_id = s.id - JOIN theme t ON s.theme_id = t.id - JOIN reservation_time rt ON s.time_id = rt.id - JOIN member m ON r.member_id = m.id - ORDER BY r.id - """; - - return template.query(sql, reservationDetailFindRowMapper); - } - - @Override - public Set findTimeIdByDateAndThemeId(LocalDate date, long themeId) { - String sql = """ - SELECT - s.time_id - FROM slot s - LEFT JOIN reservation r ON s.id = r.slot_id - WHERE s.date = :date - AND s.theme_id = :themeId - AND r.id IS NOT NULL - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", date) - .addValue("themeId", themeId); - - return Set.copyOf(template.query(sql, params, - (rs, rowNum) -> rs.getLong("time_id"))); - } - - @Override - public List findAllReservationDetailsByMemberId(long memberId) { - String sql = """ - SELECT - r.id AS reservation_id, - m.id AS member_id, - m.name AS member_name, - s.date, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url, - rt.id AS time_id, - rt.start_at - FROM reservation r - JOIN slot s ON r.slot_id = s.id - JOIN theme t ON s.theme_id = t.id - JOIN reservation_time rt ON s.time_id = rt.id - JOIN member m ON r.member_id = m.id - WHERE m.id = :memberId - """; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("memberId", memberId); - - return template.query(sql, params, reservationDetailFindRowMapper); - } - - @Override - public void deleteById(long reservationId) { - String sql = "DELETE FROM reservation WHERE id = :reservationId"; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("reservationId", reservationId); - template.update(sql, params); - } - - @Override - public Optional findById(long reservationId) { - String sql = """ - SELECT - r.id AS reservation_id, - r.member_id, - s.id AS slot_id, - s.date, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url - FROM reservation r - JOIN slot s ON r.slot_id = s.id - JOIN reservation_time rt ON s.time_id = rt.id - JOIN theme t ON s.theme_id = t.id - WHERE r.id = :id - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("id", reservationId); - - return template.query(sql, params, reservationRowMapper) - .stream() - .findFirst(); - } - - @Override - public boolean existsBySlotId(long slotId) { - String sql = "SELECT EXISTS (SELECT 1 FROM reservation WHERE slot_id = :slotId)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("slotId", slotId); - - return Boolean.TRUE.equals(template.queryForObject(sql, params, Boolean.class)); - } - - @Override - public boolean existsByMemberIdAndSlotId(long memberId, long slotId) { - String sql = "SELECT EXISTS (SELECT 1 FROM reservation WHERE member_id = :memberId AND slot_id = :slotId)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("memberId", memberId) - .addValue("slotId", slotId); - - return Boolean.TRUE.equals(template.queryForObject(sql, params, Boolean.class)); - } -} diff --git a/src/main/java/roomescape/reservation/adapter/out/persistence/JpaReservationRepository.java b/src/main/java/roomescape/reservation/adapter/out/persistence/JpaReservationRepository.java new file mode 100644 index 0000000000..b4f90820df --- /dev/null +++ b/src/main/java/roomescape/reservation/adapter/out/persistence/JpaReservationRepository.java @@ -0,0 +1,58 @@ +package roomescape.reservation.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import roomescape.reservation.application.port.out.ReservationRepository; +import roomescape.reservation.application.port.out.projection.ReservationDetailProjection; +import roomescape.reservation.domain.Reservation; + +@Repository +@RequiredArgsConstructor +public class JpaReservationRepository implements ReservationRepository { + private final SpringDataReservationRepository repository; + + @Override + public Reservation save(Reservation reservation) { + return repository.save(reservation); + } + + @Override + public List findAll() { + return repository.findAllDetails(); + } + + @Override + public Set findTimeIdByDateAndThemeId(LocalDate date, long themeId) { + return Set.copyOf(repository.findTimeIdsByDateAndThemeId(date, themeId)); + } + + @Override + public List findAllReservationDetailsByMemberId(long memberId) { + return repository.findAllReservationDetailsByMemberId(memberId); + } + + @Override + public void deleteById(long reservationId) { + repository.deleteById(reservationId); + repository.flush(); + } + + @Override + public Optional findById(long reservationId) { + return repository.findById(reservationId); + } + + @Override + public boolean existsBySlotId(long slotId) { + return repository.existsBySlot_Id(slotId); + } + + @Override + public boolean existsByMemberIdAndSlotId(long memberId, long slotId) { + return repository.existsByMember_IdAndSlot_Id(memberId, slotId); + } +} diff --git a/src/main/java/roomescape/reservation/adapter/out/persistence/SpringDataReservationRepository.java b/src/main/java/roomescape/reservation/adapter/out/persistence/SpringDataReservationRepository.java new file mode 100644 index 0000000000..015b289f70 --- /dev/null +++ b/src/main/java/roomescape/reservation/adapter/out/persistence/SpringDataReservationRepository.java @@ -0,0 +1,60 @@ +package roomescape.reservation.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.reservation.application.port.out.projection.ReservationDetailProjection; +import roomescape.reservation.domain.Reservation; + +interface SpringDataReservationRepository extends JpaRepository { + boolean existsBySlot_Id(long slotId); + + boolean existsByMember_IdAndSlot_Id(long memberId, long slotId); + + @Query(""" + SELECT new roomescape.reservation.application.port.out.projection.ReservationDetailProjection( + r.id, + r.member.id, + r.member.name, + r.slot.date, + r.slot.theme.id, + r.slot.theme.name, + r.slot.theme.description, + r.slot.theme.thumbnailUrl, + r.slot.time.id, + r.slot.time.startAt + ) + FROM Reservation r + ORDER BY r.id + """) + List findAllDetails(); + + @Query(""" + SELECT r.slot.time.id + FROM Reservation r + WHERE r.slot.date = :date + AND r.slot.theme.id = :themeId + """) + List findTimeIdsByDateAndThemeId(@Param("date") LocalDate date, @Param("themeId") long themeId); + + @Query(""" + SELECT new roomescape.reservation.application.port.out.projection.ReservationDetailProjection( + r.id, + r.member.id, + r.member.name, + r.slot.date, + r.slot.theme.id, + r.slot.theme.name, + r.slot.theme.description, + r.slot.theme.thumbnailUrl, + r.slot.time.id, + r.slot.time.startAt + ) + FROM Reservation r + WHERE r.member.id = :memberId + ORDER BY r.id + """) + List findAllReservationDetailsByMemberId(@Param("memberId") long memberId); +} diff --git a/src/main/java/roomescape/reservation/application/ReservationService.java b/src/main/java/roomescape/reservation/application/ReservationService.java index 20682c4702..bac84b9a8c 100644 --- a/src/main/java/roomescape/reservation/application/ReservationService.java +++ b/src/main/java/roomescape/reservation/application/ReservationService.java @@ -10,6 +10,8 @@ import org.springframework.transaction.annotation.Transactional; import roomescape.exception.ErrorCode; import roomescape.exception.EscapeRoomException; +import roomescape.member.application.port.out.MemberRepository; +import roomescape.member.domain.Member; import roomescape.reservation.application.dto.request.ReservationSaveRequest; import roomescape.reservation.application.dto.response.ReservationDetailFindResponse; import roomescape.reservation.application.dto.response.ReservationSaveResponse; @@ -32,19 +34,26 @@ @RequiredArgsConstructor public class ReservationService implements CreateReservationUseCase, FindReservationUseCase, CancelReservationUseCase { private final ReservationRepository reservationRepository; + private final MemberRepository memberRepository; private final WaitingRepository waitingRepository; private final SlotAssembler slotAssembler; private final WaitingPromotionPolicy waitingPromotionPolicy; private final Clock clock; public ReservationSaveResponse save(ReservationSaveRequest body, long memberId) { + Member member = findMember(memberId); Slot slot = slotAssembler.assembleExisting(body.date(), body.timeId(), body.themeId()); throwIfSlotUnavailableForReservation(slot.getId()); - Reservation reservation = reservationRepository.save(Reservation.create(memberId, slot)); + Reservation reservation = reservationRepository.save(Reservation.create(member, slot)); return ReservationSaveResponse.from(reservation); } + private Member findMember(long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new EscapeRoomException(ErrorCode.MEMBER_NOT_FOUND, memberId)); + } + private void throwIfSlotUnavailableForReservation(long slotId) { boolean hasReservation = reservationRepository.existsBySlotId(slotId); boolean hasWaiting = waitingRepository.existsBySlotId(slotId); @@ -127,7 +136,7 @@ private List findMyWaitingResponses(long memberId return waitingDetails.stream() .map(waitingDetail -> ReservationDetailFindResponse.from( waitingDetail, - waitingOrderOf(memberId, waitingDetail, waitingLines) + waitingOrderOf(waitingDetail, waitingLines) )) .toList(); } @@ -142,12 +151,10 @@ private WaitingLines findWaitingLines(List waitingDetai } private long waitingOrderOf( - long memberId, WaitingDetailProjection waitingDetail, WaitingLines waitingLines ) { - Waiting waiting = Waiting.of(waitingDetail.id(), memberId, waitingDetail.slotId()); - return waitingLines.orderOf(waiting); + return waitingLines.orderOf(waitingDetail.slotId(), waitingDetail.id()); } private List mergeMyReservations( diff --git a/src/main/java/roomescape/reservation/domain/Reservation.java b/src/main/java/roomescape/reservation/domain/Reservation.java index 68c4f39a2a..675c29e427 100644 --- a/src/main/java/roomescape/reservation/domain/Reservation.java +++ b/src/main/java/roomescape/reservation/domain/Reservation.java @@ -1,34 +1,57 @@ package roomescape.reservation.domain; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; import java.util.Objects; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.exception.ErrorCode; import roomescape.exception.EscapeRoomException; +import roomescape.member.domain.Member; import roomescape.slot.domain.Slot; @Getter +@Entity +@Table(uniqueConstraints = @UniqueConstraint(name = "uk_reservation_slot", columnNames = "slot_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Reservation { - private final Long id; - private final Long memberId; - private final Slot slot; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - private Reservation(Long id, Long memberId, Slot slot) { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "slot_id", nullable = false) + private Slot slot; + + private Reservation(Long id, Member member, Slot slot) { this.id = id; - this.memberId = Objects.requireNonNull(memberId, "memberId는 null일 수 없습니다."); + this.member = Objects.requireNonNull(member, "member는 null일 수 없습니다."); this.slot = Objects.requireNonNull(slot, "slot은 null일 수 없습니다."); } - public static Reservation create(long memberId, Slot slot) { - return new Reservation(null, memberId, slot); + public static Reservation create(Member member, Slot slot) { + return new Reservation(null, member, slot); } - public static Reservation of(Long id, Long memberId, Slot slot) { - return new Reservation(id, memberId, slot); + public static Reservation of(Long id, Member member, Slot slot) { + return new Reservation(id, member, slot); } public boolean isOwnedBy(Long memberId) { - return Objects.equals(this.memberId, memberId); + return Objects.equals(getMemberId(), memberId); } public void validateOwnedBy(Long memberId) { @@ -49,4 +72,8 @@ public Long getSlotId() { return slot.getId(); } + public Long getMemberId() { + return member.getId(); + } + } diff --git a/src/main/java/roomescape/reservationtime/adapter/out/persistence/JdbcReservationTimeRepository.java b/src/main/java/roomescape/reservationtime/adapter/out/persistence/JdbcReservationTimeRepository.java deleted file mode 100644 index ab5da7e8de..0000000000 --- a/src/main/java/roomescape/reservationtime/adapter/out/persistence/JdbcReservationTimeRepository.java +++ /dev/null @@ -1,93 +0,0 @@ -package roomescape.reservationtime.adapter.out.persistence; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.reservationtime.application.port.out.ReservationTimeRepository; -import roomescape.reservationtime.domain.ReservationTime; - -@Repository -@RequiredArgsConstructor -public class JdbcReservationTimeRepository implements ReservationTimeRepository { - private final NamedParameterJdbcTemplate template; - private final RowMapper reservationTimeRowMapper = (resultSet, rowNum) -> - new ReservationTime( - resultSet.getLong("id"), - resultSet.getTime("start_at").toLocalTime() - ); - - @Override - public ReservationTime save(ReservationTime time) { - String sql = "INSERT INTO reservation_time(start_at) VALUES (:start_at)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("start_at", time.startAt()); - - KeyHolder keyHolder = new GeneratedKeyHolder(); - template.update(sql, params, keyHolder); - - Number id = keyHolder.getKey(); - if (id == null) { - throw new IllegalStateException("reservation_time 저장 후 생성된 ID를 반환받지 못했습니다."); - } - - return new ReservationTime(keyHolder.getKey().longValue(), time.startAt()); - } - - @Override - public List findAll() { - String sql = "SELECT id, start_at FROM reservation_time"; - - return template.query(sql, reservationTimeRowMapper); - } - - @Override - public void deleteById(Long id) { - String sql = "DELETE FROM reservation_time WHERE id = :id"; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("id", id); - - template.update(sql, params); - } - - public Optional findById(long timeId) { - String sql = "SELECT * FROM reservation_time WHERE id = :timeId"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("timeId", timeId); - - return template.query(sql, params, reservationTimeRowMapper).stream().findFirst(); - } - - - @Override - public List findTimesByDateAndThemeId(LocalDate date, long themeId) { - String sql = "SELECT rt.id, rt.start_at FROM slot s " + - "JOIN reservation_time rt ON s.time_id = rt.id " + - "WHERE s.date = :date AND s.theme_id = :themeId"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", date) - .addValue("themeId", themeId); - - return template.query(sql, params, reservationTimeRowMapper); - } - - @Override - public boolean existsAlreadyTime(LocalTime startAt) { - String sql = "SELECT EXISTS (SELECT 1 FROM reservation_time rt WHERE start_at = :startAt)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("startAt", startAt); - - return Boolean.TRUE.equals(template.queryForObject(sql, params, Boolean.class)); - } -} diff --git a/src/main/java/roomescape/reservationtime/adapter/out/persistence/JpaReservationTimeRepository.java b/src/main/java/roomescape/reservationtime/adapter/out/persistence/JpaReservationTimeRepository.java new file mode 100644 index 0000000000..b5af0d4377 --- /dev/null +++ b/src/main/java/roomescape/reservationtime/adapter/out/persistence/JpaReservationTimeRepository.java @@ -0,0 +1,46 @@ +package roomescape.reservationtime.adapter.out.persistence; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import roomescape.reservationtime.application.port.out.ReservationTimeRepository; +import roomescape.reservationtime.domain.ReservationTime; + +@Repository +@RequiredArgsConstructor +public class JpaReservationTimeRepository implements ReservationTimeRepository { + private final SpringDataReservationTimeRepository repository; + + @Override + public ReservationTime save(ReservationTime time) { + return repository.save(time); + } + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public void deleteById(Long id) { + repository.deleteById(id); + } + + @Override + public Optional findById(long id) { + return repository.findById(id); + } + + @Override + public List findTimesByDateAndThemeId(LocalDate date, long themeId) { + return repository.findTimesByDateAndThemeId(date, themeId); + } + + @Override + public boolean existsAlreadyTime(LocalTime startAt) { + return repository.existsByStartAt(startAt); + } +} diff --git a/src/main/java/roomescape/reservationtime/adapter/out/persistence/SpringDataReservationTimeRepository.java b/src/main/java/roomescape/reservationtime/adapter/out/persistence/SpringDataReservationTimeRepository.java new file mode 100644 index 0000000000..df917ba573 --- /dev/null +++ b/src/main/java/roomescape/reservationtime/adapter/out/persistence/SpringDataReservationTimeRepository.java @@ -0,0 +1,25 @@ +package roomescape.reservationtime.adapter.out.persistence; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.reservationtime.domain.ReservationTime; + +interface SpringDataReservationTimeRepository extends JpaRepository { + boolean existsByStartAt(LocalTime startAt); + + @Query(""" + SELECT rt + FROM Slot s + JOIN s.time rt + WHERE s.date = :date + AND s.theme.id = :themeId + """) + List findTimesByDateAndThemeId( + @Param("date") LocalDate date, + @Param("themeId") long themeId + ); +} diff --git a/src/main/java/roomescape/reservationtime/domain/ReservationTime.java b/src/main/java/roomescape/reservationtime/domain/ReservationTime.java index f8c255ef04..6691fc3988 100644 --- a/src/main/java/roomescape/reservationtime/domain/ReservationTime.java +++ b/src/main/java/roomescape/reservationtime/domain/ReservationTime.java @@ -1,6 +1,38 @@ package roomescape.reservationtime.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import java.time.LocalTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; -public record ReservationTime(Long id, LocalTime startAt) { +@Getter +@Entity +@Table(name = "reservation_time") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "start_at", nullable = false) + private LocalTime startAt; + + public ReservationTime(Long id, LocalTime startAt) { + this.id = id; + this.startAt = startAt; + } + + public Long id() { + return id; + } + + public LocalTime startAt() { + return startAt; + } } diff --git a/src/main/java/roomescape/slot/adapter/out/persistence/JdbcSlotRepository.java b/src/main/java/roomescape/slot/adapter/out/persistence/JdbcSlotRepository.java deleted file mode 100644 index 0851c506d5..0000000000 --- a/src/main/java/roomescape/slot/adapter/out/persistence/JdbcSlotRepository.java +++ /dev/null @@ -1,191 +0,0 @@ -package roomescape.slot.adapter.out.persistence; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.exception.ErrorCode; -import roomescape.exception.EscapeRoomException; -import roomescape.reservationtime.domain.ReservationTime; -import roomescape.slot.application.port.out.SlotRepository; -import roomescape.slot.domain.Slot; -import roomescape.theme.domain.Theme; - -@Repository -@RequiredArgsConstructor -public class JdbcSlotRepository implements SlotRepository { - private final NamedParameterJdbcTemplate template; - private final RowMapper slotRowMapper = (resultSet, rowNum) -> { - ReservationTime time = new ReservationTime( - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - Theme theme = new Theme( - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("theme_description"), - resultSet.getString("theme_thumbnail_url") - ); - return Slot.of( - resultSet.getLong("id"), - resultSet.getDate("date").toLocalDate(), - time, - theme - ); - }; - - @Override - public Slot save(Slot slot) { - String sql = "INSERT INTO slot(date, time_id, theme_id) VALUES (:date, :timeId, :themeId)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", slot.getDate()) - .addValue("timeId", slot.getTimeId()) - .addValue("themeId", slot.getThemeId()); - - KeyHolder keyHolder = new GeneratedKeyHolder(); - template.update(sql, params, keyHolder); - - Number id = keyHolder.getKey(); - if (id == null) { - throw new IllegalStateException("slot 저장 후 생성된 ID를 반환받지 못했습니다."); - } - - return Slot.of( - id.longValue(), - slot.getDate(), - slot.getTime(), - slot.getTheme() - ); - } - - @Override - public Optional findByDateAndTimeIdAndThemeId(LocalDate date, long timeId, long themeId) { - String sql = """ - SELECT - s.id, - s.date, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url - FROM slot s - JOIN reservation_time rt ON s.time_id = rt.id - JOIN theme t ON s.theme_id = t.id - WHERE s.date = :date - AND s.time_id = :timeId - AND s.theme_id = :themeId - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", date) - .addValue("timeId", timeId) - .addValue("themeId", themeId); - - return template.query(sql, params, slotRowMapper) - .stream() - .findFirst(); - } - - @Override - public Optional findById(long id) { - String sql = """ - SELECT - s.id, - s.date, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url - FROM slot s - JOIN reservation_time rt ON s.time_id = rt.id - JOIN theme t ON s.theme_id = t.id - WHERE s.id = :id - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("id", id); - - return template.query(sql, params, slotRowMapper).stream().findFirst(); - } - - @Override - public boolean existsByTimeId(long timeId) { - String sql = "SELECT COUNT(1) FROM slot WHERE time_id = :timeId"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("timeId", timeId); - - Integer count = template.queryForObject(sql, params, Integer.class); - return count != null && count > 0; - } - - @Override - public boolean existsByThemeId(long themeId) { - String sql = "SELECT COUNT(1) FROM slot WHERE theme_id = :themeId"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("themeId", themeId); - - Integer count = template.queryForObject(sql, params, Integer.class); - return count != null && count > 0; - } - - @Override - public List findAll() { - String sql = """ - SELECT - s.id, - s.date, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url - FROM slot s - JOIN reservation_time rt ON s.time_id = rt.id - JOIN theme t ON s.theme_id = t.id - """; - - MapSqlParameterSource params = new MapSqlParameterSource(); - return template.query(sql, params, slotRowMapper); - } - - @Override - public void deleteById(long slotId) { - String sql = "DELETE FROM slot WHERE id = :slotId"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("slotId", slotId); - - try { - template.update(sql, params); - } catch (DataIntegrityViolationException e) { - throw new EscapeRoomException(ErrorCode.SLOT_IN_USE, slotId); - } - } - - @Override - public boolean existsByDateAndThemeIdAndTimeId(LocalDate date, long themeId, long timeId) { - String sql = "SELECT EXISTS (SELECT 1 FROM slot WHERE date = :date AND time_id = :timeId AND theme_id = :themeId)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", date) - .addValue("timeId", timeId) - .addValue("themeId", themeId); - - return Boolean.TRUE.equals(template.queryForObject(sql, params, Boolean.class)); - } -} diff --git a/src/main/java/roomescape/slot/adapter/out/persistence/JpaSlotRepository.java b/src/main/java/roomescape/slot/adapter/out/persistence/JpaSlotRepository.java new file mode 100644 index 0000000000..4f985a4a37 --- /dev/null +++ b/src/main/java/roomescape/slot/adapter/out/persistence/JpaSlotRepository.java @@ -0,0 +1,63 @@ +package roomescape.slot.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import roomescape.exception.ErrorCode; +import roomescape.exception.EscapeRoomException; +import roomescape.slot.application.port.out.SlotRepository; +import roomescape.slot.domain.Slot; + +@Repository +@RequiredArgsConstructor +public class JpaSlotRepository implements SlotRepository { + private final SpringDataSlotRepository repository; + + @Override + public Slot save(Slot slot) { + return repository.save(slot); + } + + @Override + public Optional findByDateAndTimeIdAndThemeId(LocalDate date, long timeId, long themeId) { + return repository.findByDateAndTime_IdAndTheme_Id(date, timeId, themeId); + } + + @Override + public Optional findById(long id) { + return repository.findById(id); + } + + @Override + public boolean existsByTimeId(long timeId) { + return repository.existsByTime_Id(timeId); + } + + @Override + public boolean existsByThemeId(long themeId) { + return repository.existsByTheme_Id(themeId); + } + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public void deleteById(long id) { + try { + repository.deleteById(id); + repository.flush(); + } catch (DataIntegrityViolationException e) { + throw new EscapeRoomException(ErrorCode.SLOT_IN_USE, id); + } + } + + @Override + public boolean existsByDateAndThemeIdAndTimeId(LocalDate date, long themeId, long timeId) { + return repository.existsByDateAndTheme_IdAndTime_Id(date, themeId, timeId); + } +} diff --git a/src/main/java/roomescape/slot/adapter/out/persistence/SpringDataSlotRepository.java b/src/main/java/roomescape/slot/adapter/out/persistence/SpringDataSlotRepository.java new file mode 100644 index 0000000000..d7c3e06acf --- /dev/null +++ b/src/main/java/roomescape/slot/adapter/out/persistence/SpringDataSlotRepository.java @@ -0,0 +1,16 @@ +package roomescape.slot.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import roomescape.slot.domain.Slot; + +interface SpringDataSlotRepository extends JpaRepository { + Optional findByDateAndTime_IdAndTheme_Id(LocalDate date, long timeId, long themeId); + + boolean existsByTime_Id(long timeId); + + boolean existsByTheme_Id(long themeId); + + boolean existsByDateAndTheme_IdAndTime_Id(LocalDate date, long themeId, long timeId); +} diff --git a/src/main/java/roomescape/slot/domain/Slot.java b/src/main/java/roomescape/slot/domain/Slot.java index 616cf8cddc..21e4d73777 100644 --- a/src/main/java/roomescape/slot/domain/Slot.java +++ b/src/main/java/roomescape/slot/domain/Slot.java @@ -1,21 +1,51 @@ package roomescape.slot.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Objects; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.exception.ErrorCode; import roomescape.exception.EscapeRoomException; import roomescape.reservationtime.domain.ReservationTime; import roomescape.theme.domain.Theme; @Getter +@Entity +@Table( + uniqueConstraints = @UniqueConstraint( + name = "uk_slot_date_time_theme", + columnNames = {"date", "time_id", "theme_id"} + ) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Slot { - private final Long id; - private final LocalDate date; - private final ReservationTime time; - private final Theme theme; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate date; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id", nullable = false) + private ReservationTime time; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id", nullable = false) + private Theme theme; private Slot(Long id, LocalDate date, ReservationTime time, Theme theme) { this.id = id; diff --git a/src/main/java/roomescape/theme/adapter/out/persistence/JdbcThemeRepository.java b/src/main/java/roomescape/theme/adapter/out/persistence/JdbcThemeRepository.java deleted file mode 100644 index 42d03f331e..0000000000 --- a/src/main/java/roomescape/theme/adapter/out/persistence/JdbcThemeRepository.java +++ /dev/null @@ -1,126 +0,0 @@ -package roomescape.theme.adapter.out.persistence; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.theme.application.port.out.ThemeRepository; -import roomescape.theme.domain.Theme; - -@Repository -@RequiredArgsConstructor -public class JdbcThemeRepository implements ThemeRepository { - private final NamedParameterJdbcTemplate template; - private final RowMapper themeRowMapper = (resultSet, rowNum) -> - new Theme( - resultSet.getLong("id"), - resultSet.getString("name"), - resultSet.getString("description"), - resultSet.getString("thumbnail_url") - ); - - @Override - public Theme save(Theme theme) { - String sql = "INSERT INTO theme(name, description, thumbnail_url) VALUES (:name, :description, :thumbnail_url)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("name", theme.getName()) - .addValue("description", theme.getDescription()) - .addValue("thumbnail_url", theme.getThumbnailUrl()); - - KeyHolder keyHolder = new GeneratedKeyHolder(); - template.update(sql, params, keyHolder); - - Number id = keyHolder.getKey(); - if (id == null) { - throw new IllegalStateException("theme 저장 후 생성된 ID를 반환받지 못했습니다."); - } - - return new Theme( - keyHolder.getKey().longValue(), - theme.getName(), - theme.getDescription(), - theme.getThumbnailUrl() - ); - } - - @Override - public void deleteById(long id) { - String sql = "DELETE FROM theme WHERE id = :id"; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("id", id); - - template.update(sql, params); - } - - @Override - public List findThemesBySlotDate(LocalDate date) { - String sql = "SELECT DISTINCT t.id, t.name, t.description, t.thumbnail_url " + - "FROM theme t " + - "JOIN slot s ON t.id = s.theme_id " + - "WHERE s.date = :date " + - "ORDER BY t.id ASC"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", date); - - return template.query(sql, params, themeRowMapper); - } - - @Override - public List findPopularThemeByCurrentDate(LocalDate currentDate) { - LocalDate startDate = currentDate.minusDays(7); - - String sql = "SELECT t.id, t.name, t.description, t.thumbnail_url " + - "FROM theme t " + - "JOIN slot s ON t.id = s.theme_id " + - "JOIN reservation r ON s.id = r.slot_id " + - "WHERE s.date >= :startDate " + - "AND s.date < :currentDate " + - "GROUP BY t.id, t.name, t.description, t.thumbnail_url " + - "ORDER BY COUNT(r.id) DESC, t.id ASC " + - "LIMIT :limit"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("startDate", startDate) - .addValue("currentDate", currentDate) - .addValue("limit", 10); - - return template.query(sql, params, themeRowMapper); - } - - @Override - public Optional findById(long id) { - String sql = "SELECT * FROM theme WHERE id = :id"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("id", id); - - List result = template.query(sql, params, themeRowMapper); - - return result.stream().findFirst(); - } - - @Override - public List findAll() { - String sql = "SELECT * FROM theme"; - - return template.query(sql, themeRowMapper); - } - - @Override - public boolean existsAlreadyTheme(String themeName) { - String sql = "SELECT EXISTS (SELECT 1 FROM theme WHERE name = :themeName)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("themeName", themeName); - - return Boolean.TRUE.equals(template.queryForObject(sql, params, Boolean.class)); - } -} diff --git a/src/main/java/roomescape/theme/adapter/out/persistence/JpaThemeRepository.java b/src/main/java/roomescape/theme/adapter/out/persistence/JpaThemeRepository.java new file mode 100644 index 0000000000..1ada83b630 --- /dev/null +++ b/src/main/java/roomescape/theme/adapter/out/persistence/JpaThemeRepository.java @@ -0,0 +1,58 @@ +package roomescape.theme.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; +import roomescape.theme.application.port.out.ThemeRepository; +import roomescape.theme.domain.Theme; + +@Repository +@RequiredArgsConstructor +public class JpaThemeRepository implements ThemeRepository { + private static final int POPULAR_THEME_LIMIT = 10; + + private final SpringDataThemeRepository repository; + + @Override + public Theme save(Theme domain) { + return repository.save(domain); + } + + @Override + public void deleteById(long id) { + repository.deleteById(id); + } + + @Override + public List findThemesBySlotDate(LocalDate date) { + return repository.findThemesBySlotDate(date); + } + + @Override + public List findPopularThemeByCurrentDate(LocalDate currentDate) { + LocalDate startDate = currentDate.minusDays(7); + return repository.findPopularThemeByCurrentDate( + startDate, + currentDate, + PageRequest.of(0, POPULAR_THEME_LIMIT) + ); + } + + @Override + public Optional findById(long id) { + return repository.findById(id); + } + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public boolean existsAlreadyTheme(String name) { + return repository.existsByName(name); + } +} diff --git a/src/main/java/roomescape/theme/adapter/out/persistence/SpringDataThemeRepository.java b/src/main/java/roomescape/theme/adapter/out/persistence/SpringDataThemeRepository.java new file mode 100644 index 0000000000..146a4551b0 --- /dev/null +++ b/src/main/java/roomescape/theme/adapter/out/persistence/SpringDataThemeRepository.java @@ -0,0 +1,38 @@ +package roomescape.theme.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.theme.domain.Theme; + +interface SpringDataThemeRepository extends JpaRepository { + boolean existsByName(String name); + + @Query(""" + SELECT DISTINCT t + FROM Slot s + JOIN s.theme t + WHERE s.date = :date + ORDER BY t.id ASC + """) + List findThemesBySlotDate(@Param("date") LocalDate date); + + @Query(""" + SELECT t + FROM Reservation r + JOIN r.slot s + JOIN s.theme t + WHERE s.date >= :startDate + AND s.date < :currentDate + GROUP BY t + ORDER BY COUNT(r.id) DESC, t.id ASC + """) + List findPopularThemeByCurrentDate( + @Param("startDate") LocalDate startDate, + @Param("currentDate") LocalDate currentDate, + Pageable pageable + ); +} diff --git a/src/main/java/roomescape/theme/domain/Theme.java b/src/main/java/roomescape/theme/domain/Theme.java index 776a6694fb..d886c3ff69 100644 --- a/src/main/java/roomescape/theme/domain/Theme.java +++ b/src/main/java/roomescape/theme/domain/Theme.java @@ -1,13 +1,30 @@ package roomescape.theme.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@Entity @AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Theme { - private final Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) private String name; + + @Column(columnDefinition = "TEXT") private String description; + + @Column(name = "thumbnail_url", nullable = false) private String thumbnailUrl; } diff --git a/src/main/java/roomescape/waiting/adapter/in/web/WaitingController.java b/src/main/java/roomescape/waiting/adapter/in/web/WaitingController.java index d3ff5f24a9..358936a0fb 100644 --- a/src/main/java/roomescape/waiting/adapter/in/web/WaitingController.java +++ b/src/main/java/roomescape/waiting/adapter/in/web/WaitingController.java @@ -2,32 +2,35 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import roomescape.common.api.ApiResponse; import roomescape.member.domain.AuthenticatedMember; import roomescape.member.domain.LoginMember; import roomescape.waiting.application.dto.request.WaitingRequest; +import roomescape.waiting.application.dto.response.WaitingDetailFindResponse; import roomescape.waiting.application.dto.response.WaitingResponse; import roomescape.waiting.application.port.in.CancelWaitingUseCase; import roomescape.waiting.application.port.in.CreateWaitingUseCase; +import roomescape.waiting.application.port.in.FindWaitingUseCase; @RestController -@RequestMapping("/api/user/waitings") @RequiredArgsConstructor public class WaitingController { private final CreateWaitingUseCase createWaitingUseCase; private final CancelWaitingUseCase cancelWaitingUseCase; + private final FindWaitingUseCase findWaitingUseCase; - @PostMapping + @PostMapping({"/api/user/waitings", "/waitings"}) public ResponseEntity> save( @RequestBody @Valid WaitingRequest body, @LoginMember AuthenticatedMember member @@ -36,7 +39,7 @@ public ResponseEntity> save( return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); } - @DeleteMapping("/{id}") + @DeleteMapping({"/api/user/waitings/{id}", "/waitings/{id}"}) public ResponseEntity> deleteByUser( @PathVariable @Positive long id, @LoginMember AuthenticatedMember member @@ -44,4 +47,18 @@ public ResponseEntity> deleteByUser( cancelWaitingUseCase.deleteByIdForUser(id, member.id()); return ResponseEntity.noContent().build(); } + + @GetMapping("/api/manager/waitings") + public ResponseEntity>> findWaitingDetails() { + List responses = findWaitingUseCase.findWaitingDetails(); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(responses)); + } + + @DeleteMapping("/api/manager/waitings/{id}") + public ResponseEntity> deleteByManager( + @PathVariable @Positive long id + ) { + cancelWaitingUseCase.deleteById(id); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/roomescape/waiting/adapter/out/persistence/JdbcWaitingRepository.java b/src/main/java/roomescape/waiting/adapter/out/persistence/JdbcWaitingRepository.java deleted file mode 100644 index d0080bb3b1..0000000000 --- a/src/main/java/roomescape/waiting/adapter/out/persistence/JdbcWaitingRepository.java +++ /dev/null @@ -1,222 +0,0 @@ -package roomescape.waiting.adapter.out.persistence; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.waiting.application.port.out.WaitingRepository; -import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; -import roomescape.waiting.domain.Waiting; - -@Repository -@RequiredArgsConstructor -public class JdbcWaitingRepository implements WaitingRepository { - - private final NamedParameterJdbcTemplate jdbcTemplate; - - private final RowMapper waitingRowMapper = (resultSet, rowNum) -> Waiting.of( - resultSet.getLong("id"), - resultSet.getLong("member_id"), - resultSet.getLong("slot_id") - ); - - private final RowMapper waitingDetailRowMapper = (resultSet, rowNum) -> - new WaitingDetailProjection( - resultSet.getLong("waiting_id"), - resultSet.getLong("slot_id"), - resultSet.getString("member_name"), - resultSet.getDate("date").toLocalDate(), - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("theme_description"), - resultSet.getString("theme_thumbnail_url"), - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - - @Override - public Waiting save(Waiting waiting) { - String sql = "INSERT INTO waiting(member_id, slot_id) VALUES (:memberId, :slotId)"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("memberId", waiting.getMemberId()) - .addValue("slotId", waiting.getSlotId()); - - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(sql, params, keyHolder); - - Number id = keyHolder.getKey(); - if (id == null) { - throw new IllegalStateException("waiting 저장 후 생성된 ID를 반환받지 못했습니다."); - } - - return Waiting.of(id.longValue(), waiting.getMemberId(), waiting.getSlotId()); - } - - @Override - public Optional findById(long waitingId) { - String sql = """ - SELECT id, member_id, slot_id - FROM waiting - WHERE id = :waitingId - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("waitingId", waitingId); - - return jdbcTemplate.query(sql, params, waitingRowMapper) - .stream() - .findFirst(); - } - - @Override - public Optional findByIdForUpdate(long waitingId) { - String sql = """ - SELECT id, member_id, slot_id - FROM waiting - WHERE id = :waitingId - FOR UPDATE - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("waitingId", waitingId); - - return jdbcTemplate.query(sql, params, waitingRowMapper) - .stream() - .findFirst(); - } - - @Override - public Set findTimeIdByDateAndThemeId(LocalDate date, long themeId) { - String sql = """ - SELECT s.time_id - FROM waiting w - JOIN slot s ON w.slot_id = s.id - WHERE s.date = :date - AND s.theme_id = :themeId - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("date", date) - .addValue("themeId", themeId); - - return Set.copyOf(jdbcTemplate.query(sql, params, (resultSet, rowNum) -> resultSet.getLong("time_id"))); - } - - @Override - public boolean existsBySlotIdAndMemberId(long memberId, long slotId) { - String sql = """ - SELECT EXISTS (SELECT 1 FROM waiting WHERE member_id = :memberId AND slot_id = :slotId) - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("memberId", memberId) - .addValue("slotId", slotId); - - return Boolean.TRUE.equals(jdbcTemplate.queryForObject(sql, params, Boolean.class)); - } - - @Override - public boolean existsBySlotId(long slotId) { - String sql = """ - SELECT EXISTS (SELECT 1 FROM waiting WHERE slot_id = :slotId) - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("slotId", slotId); - - return Boolean.TRUE.equals(jdbcTemplate.queryForObject(sql, params, Boolean.class)); - } - - @Override - public List findAllBySlotIdOrderById(long slotId) { - String sql = """ - SELECT id, member_id, slot_id - FROM waiting - WHERE slot_id = :slotId - ORDER BY id - """; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("slotId", slotId); - - return jdbcTemplate.query(sql, params, waitingRowMapper); - } - - @Override - public List findAllBySlotIdOrderByIdForUpdate(long slotId) { - String sql = """ - SELECT id, member_id, slot_id - FROM waiting - WHERE slot_id = :slotId - ORDER BY id - FOR UPDATE - """; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("slotId", slotId); - - return jdbcTemplate.query(sql, params, waitingRowMapper); - } - - @Override - public List findAllBySlotIds(List slotIds) { - if (slotIds.isEmpty()) { - return List.of(); - } - - String sql = """ - SELECT id, member_id, slot_id - FROM waiting - WHERE slot_id IN (:slotIds) - """; - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("slotIds", slotIds); - - return jdbcTemplate.query(sql, params, waitingRowMapper); - } - - @Override - public List findAllWaitingDetailsByMemberId(long memberId) { - String sql = """ - SELECT - w.id AS waiting_id, - w.slot_id, - m.name AS member_name, - s.date, - t.id AS theme_id, - t.name AS theme_name, - t.description AS theme_description, - t.thumbnail_url AS theme_thumbnail_url, - rt.id AS time_id, - rt.start_at - FROM waiting w - JOIN slot s ON w.slot_id = s.id - JOIN theme t ON s.theme_id = t.id - JOIN reservation_time rt ON s.time_id = rt.id - JOIN member m ON w.member_id = m.id - WHERE m.id = :memberId - ORDER BY w.id - """; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("memberId", memberId); - - return jdbcTemplate.query(sql, params, waitingDetailRowMapper); - } - - @Override - public void deleteById(long waitingId) { - String sql = "DELETE FROM waiting WHERE id = :waitingId"; - - MapSqlParameterSource params = new MapSqlParameterSource() - .addValue("waitingId", waitingId); - - jdbcTemplate.update(sql, params); - } -} diff --git a/src/main/java/roomescape/waiting/adapter/out/persistence/JpaWaitingRepository.java b/src/main/java/roomescape/waiting/adapter/out/persistence/JpaWaitingRepository.java new file mode 100644 index 0000000000..e7dbc516d3 --- /dev/null +++ b/src/main/java/roomescape/waiting/adapter/out/persistence/JpaWaitingRepository.java @@ -0,0 +1,80 @@ +package roomescape.waiting.adapter.out.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import roomescape.waiting.application.port.out.WaitingRepository; +import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; +import roomescape.waiting.domain.Waiting; + +@Repository +@RequiredArgsConstructor +public class JpaWaitingRepository implements WaitingRepository { + private final SpringDataWaitingRepository repository; + + @Override + public Waiting save(Waiting waiting) { + return repository.save(waiting); + } + + @Override + public Optional findById(long waitingId) { + return repository.findById(waitingId); + } + + @Override + public Optional findByIdForUpdate(long waitingId) { + return repository.findByIdForUpdate(waitingId); + } + + @Override + public Set findTimeIdByDateAndThemeId(LocalDate date, long themeId) { + return Set.copyOf(repository.findTimeIdsByDateAndThemeId(date, themeId)); + } + + @Override + public boolean existsBySlotIdAndMemberId(long memberId, long slotId) { + return repository.existsBySlot_IdAndMember_Id(slotId, memberId); + } + + @Override + public boolean existsBySlotId(long slotId) { + return repository.existsBySlot_Id(slotId); + } + + @Override + public List findAllBySlotIdOrderById(long slotId) { + return repository.findAllBySlot_IdOrderById(slotId); + } + + @Override + public List findAllBySlotIdOrderByIdForUpdate(long slotId) { + return repository.findAllBySlotIdOrderByIdForUpdate(slotId); + } + + @Override + public List findAllBySlotIds(List slotIds) { + if (slotIds.isEmpty()) { + return List.of(); + } + return repository.findAllBySlot_IdIn(slotIds); + } + + @Override + public List findAllWaitingDetails() { + return repository.findAllWaitingDetails(); + } + + @Override + public List findAllWaitingDetailsByMemberId(long memberId) { + return repository.findAllWaitingDetailsByMemberId(memberId); + } + + @Override + public void deleteById(long waitingId) { + repository.deleteById(waitingId); + } +} diff --git a/src/main/java/roomescape/waiting/adapter/out/persistence/SpringDataWaitingRepository.java b/src/main/java/roomescape/waiting/adapter/out/persistence/SpringDataWaitingRepository.java new file mode 100644 index 0000000000..a7665a1170 --- /dev/null +++ b/src/main/java/roomescape/waiting/adapter/out/persistence/SpringDataWaitingRepository.java @@ -0,0 +1,75 @@ +package roomescape.waiting.adapter.out.persistence; + +import jakarta.persistence.LockModeType; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; +import roomescape.waiting.domain.Waiting; + +interface SpringDataWaitingRepository extends JpaRepository { + boolean existsBySlot_IdAndMember_Id(long slotId, long memberId); + + boolean existsBySlot_Id(long slotId); + + List findAllBySlot_IdOrderById(long slotId); + + List findAllBySlot_IdIn(List slotIds); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT w FROM Waiting w WHERE w.id = :id") + Optional findByIdForUpdate(@Param("id") long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT w FROM Waiting w WHERE w.slot.id = :slotId ORDER BY w.id") + List findAllBySlotIdOrderByIdForUpdate(@Param("slotId") long slotId); + + @Query(""" + SELECT w.slot.time.id + FROM Waiting w + WHERE w.slot.date = :date + AND w.slot.theme.id = :themeId + """) + List findTimeIdsByDateAndThemeId(@Param("date") LocalDate date, @Param("themeId") long themeId); + + @Query(""" + SELECT new roomescape.waiting.application.port.out.projection.WaitingDetailProjection( + w.id, + w.slot.id, + w.member.name, + w.slot.date, + w.slot.theme.id, + w.slot.theme.name, + w.slot.theme.description, + w.slot.theme.thumbnailUrl, + w.slot.time.id, + w.slot.time.startAt + ) + FROM Waiting w + ORDER BY w.id + """) + List findAllWaitingDetails(); + + @Query(""" + SELECT new roomescape.waiting.application.port.out.projection.WaitingDetailProjection( + w.id, + w.slot.id, + w.member.name, + w.slot.date, + w.slot.theme.id, + w.slot.theme.name, + w.slot.theme.description, + w.slot.theme.thumbnailUrl, + w.slot.time.id, + w.slot.time.startAt + ) + FROM Waiting w + WHERE w.member.id = :memberId + ORDER BY w.id + """) + List findAllWaitingDetailsByMemberId(@Param("memberId") long memberId); +} diff --git a/src/main/java/roomescape/waiting/application/WaitingService.java b/src/main/java/roomescape/waiting/application/WaitingService.java index c9ace1a77c..d6fe313f96 100644 --- a/src/main/java/roomescape/waiting/application/WaitingService.java +++ b/src/main/java/roomescape/waiting/application/WaitingService.java @@ -1,32 +1,41 @@ package roomescape.waiting.application; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import roomescape.exception.ErrorCode; import roomescape.exception.EscapeRoomException; +import roomescape.member.application.port.out.MemberRepository; +import roomescape.member.domain.Member; import roomescape.reservation.application.port.out.ReservationRepository; import roomescape.slot.application.SlotAssembler; import roomescape.slot.domain.Slot; import roomescape.slot.domain.SlotOccupancy; import roomescape.waiting.application.dto.request.WaitingRequest; +import roomescape.waiting.application.dto.response.WaitingDetailFindResponse; import roomescape.waiting.application.dto.response.WaitingResponse; import roomescape.waiting.application.port.in.CancelWaitingUseCase; import roomescape.waiting.application.port.in.CreateWaitingUseCase; +import roomescape.waiting.application.port.in.FindWaitingUseCase; import roomescape.waiting.application.port.out.WaitingRepository; +import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; import roomescape.waiting.domain.Waiting; import roomescape.waiting.domain.WaitingLine; +import roomescape.waiting.domain.WaitingLines; @Service @RequiredArgsConstructor -public class WaitingService implements CreateWaitingUseCase, CancelWaitingUseCase { +public class WaitingService implements CreateWaitingUseCase, CancelWaitingUseCase, FindWaitingUseCase { private final SlotAssembler slotAssembler; + private final MemberRepository memberRepository; private final WaitingRepository waitingRepository; private final ReservationRepository reservationRepository; @Transactional public WaitingResponse save(WaitingRequest body, long memberId) { + Member member = findMember(memberId); Slot slot = slotAssembler.assembleExisting(body.date(), body.timeId(), body.themeId()); long slotId = slot.getId(); @@ -34,13 +43,18 @@ public WaitingResponse save(WaitingRequest body, long memberId) { validateWaitingByMemberNotExists(memberId, slotId); validateWaitingTargetExists(slotId); - Waiting waiting = waitingRepository.save(Waiting.create(memberId, slotId)); + Waiting waiting = waitingRepository.save(Waiting.create(member, slot)); WaitingLine waitingLine = WaitingLine.of(waitingRepository.findAllBySlotIdOrderById(slotId)); long waitingOrder = waitingLine.orderOf(waiting); return WaitingResponse.of(waiting, waitingOrder); } + private Member findMember(long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new EscapeRoomException(ErrorCode.MEMBER_NOT_FOUND, memberId)); + } + private void validateReservedByMemberNotExists(long memberId, long slotId) { if (reservationRepository.existsByMemberIdAndSlotId(memberId, slotId)) { throw new EscapeRoomException(ErrorCode.WAITING_NOT_ALLOWED_FOR_OWN_RESERVATION); @@ -64,14 +78,45 @@ private void validateWaitingTargetExists(long slotId) { } } + @Transactional + public void deleteById(long waitingId) { + findWaitingForUpdate(waitingId); + waitingRepository.deleteById(waitingId); + } + @Transactional public void deleteByIdForUser(long waitingId, long memberId) { - Waiting waiting = waitingRepository.findByIdForUpdate(waitingId) - .orElseThrow(() -> new EscapeRoomException(ErrorCode.WAITING_NOT_FOUND, waitingId)); + Waiting waiting = findWaitingForUpdate(waitingId); waiting.validateOwnedBy(memberId); waitingRepository.deleteById(waitingId); } + private Waiting findWaitingForUpdate(long waitingId) { + return waitingRepository.findByIdForUpdate(waitingId) + .orElseThrow(() -> new EscapeRoomException(ErrorCode.WAITING_NOT_FOUND, waitingId)); + } + + public List findWaitingDetails() { + List waitingDetails = waitingRepository.findAllWaitingDetails(); + WaitingLines waitingLines = findWaitingLines(waitingDetails); + + return waitingDetails.stream() + .map(waitingDetail -> WaitingDetailFindResponse.from( + waitingDetail, + waitingLines.orderOf(waitingDetail.slotId(), waitingDetail.id()) + )) + .toList(); + } + + private WaitingLines findWaitingLines(List waitingDetails) { + List slotIds = waitingDetails.stream() + .map(WaitingDetailProjection::slotId) + .distinct() + .toList(); + + return WaitingLines.of(waitingRepository.findAllBySlotIds(slotIds)); + } + } diff --git a/src/main/java/roomescape/waiting/application/dto/response/WaitingDetailFindResponse.java b/src/main/java/roomescape/waiting/application/dto/response/WaitingDetailFindResponse.java new file mode 100644 index 0000000000..3da6b84d11 --- /dev/null +++ b/src/main/java/roomescape/waiting/application/dto/response/WaitingDetailFindResponse.java @@ -0,0 +1,36 @@ +package roomescape.waiting.application.dto.response; + +import java.time.LocalDate; +import roomescape.reservationtime.application.dto.response.TimeInformation; +import roomescape.theme.application.dto.response.ThemeFindResponse; +import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; + +public record WaitingDetailFindResponse( + Long id, + Long slotId, + String memberName, + LocalDate date, + ThemeFindResponse theme, + TimeInformation time, + Long waitingOrder +) { + public static WaitingDetailFindResponse from(WaitingDetailProjection projection, long waitingOrder) { + return new WaitingDetailFindResponse( + projection.id(), + projection.slotId(), + projection.memberName(), + projection.date(), + new ThemeFindResponse( + projection.themeId(), + projection.themeName(), + projection.themeDescription(), + projection.thumbnailUrl() + ), + new TimeInformation( + projection.timeId(), + projection.startAt() + ), + waitingOrder + ); + } +} diff --git a/src/main/java/roomescape/waiting/application/port/in/CancelWaitingUseCase.java b/src/main/java/roomescape/waiting/application/port/in/CancelWaitingUseCase.java index 0b8122786e..979009af06 100644 --- a/src/main/java/roomescape/waiting/application/port/in/CancelWaitingUseCase.java +++ b/src/main/java/roomescape/waiting/application/port/in/CancelWaitingUseCase.java @@ -1,5 +1,7 @@ package roomescape.waiting.application.port.in; public interface CancelWaitingUseCase { + void deleteById(long waitingId); + void deleteByIdForUser(long waitingId, long memberId); } diff --git a/src/main/java/roomescape/waiting/application/port/in/FindWaitingUseCase.java b/src/main/java/roomescape/waiting/application/port/in/FindWaitingUseCase.java new file mode 100644 index 0000000000..d5436c5c9d --- /dev/null +++ b/src/main/java/roomescape/waiting/application/port/in/FindWaitingUseCase.java @@ -0,0 +1,8 @@ +package roomescape.waiting.application.port.in; + +import java.util.List; +import roomescape.waiting.application.dto.response.WaitingDetailFindResponse; + +public interface FindWaitingUseCase { + List findWaitingDetails(); +} diff --git a/src/main/java/roomescape/waiting/application/port/out/WaitingRepository.java b/src/main/java/roomescape/waiting/application/port/out/WaitingRepository.java index c2ecdee33b..96613e6f4d 100644 --- a/src/main/java/roomescape/waiting/application/port/out/WaitingRepository.java +++ b/src/main/java/roomescape/waiting/application/port/out/WaitingRepository.java @@ -26,6 +26,8 @@ public interface WaitingRepository { List findAllBySlotIds(List slotIds); + List findAllWaitingDetails(); + List findAllWaitingDetailsByMemberId(long memberId); void deleteById(long waitingId); diff --git a/src/main/java/roomescape/waiting/domain/Waiting.java b/src/main/java/roomescape/waiting/domain/Waiting.java index 041af359e0..2b877d75cc 100644 --- a/src/main/java/roomescape/waiting/domain/Waiting.java +++ b/src/main/java/roomescape/waiting/domain/Waiting.java @@ -1,26 +1,52 @@ package roomescape.waiting.domain; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.util.Objects; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.exception.ErrorCode; import roomescape.exception.EscapeRoomException; +import roomescape.member.domain.Member; import roomescape.slot.domain.Slot; @Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +@Table(uniqueConstraints = @UniqueConstraint(name = "uk_waiting_member_slot", columnNames = {"member_id", "slot_id"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Waiting { - private final Long id; - private final Long memberId; - private final Long slotId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - public static Waiting create(long memberId, long slotId) { - return new Waiting(null, memberId, slotId); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "slot_id", nullable = false) + private Slot slot; + + private Waiting(Long id, Member member, Slot slot) { + this.id = id; + this.member = Objects.requireNonNull(member, "member는 null일 수 없습니다."); + this.slot = Objects.requireNonNull(slot, "slot은 null일 수 없습니다."); + } + + public static Waiting create(Member member, Slot slot) { + return new Waiting(null, member, slot); } - public static Waiting of(Long id, Long memberId, Long slotId) { - return new Waiting(id, memberId, slotId); + public static Waiting of(Long id, Member member, Slot slot) { + return new Waiting(id, member, slot); } public void validateOwnedBy(long memberId) { @@ -30,11 +56,19 @@ public void validateOwnedBy(long memberId) { } public boolean isOwnedBy(Long memberId) { - return Objects.equals(this.memberId, memberId); + return Objects.equals(getMemberId(), memberId); } public boolean isFor(Slot slot) { Objects.requireNonNull(slot, "slot은 null일 수 없습니다."); - return Objects.equals(this.slotId, slot.getId()); + return Objects.equals(getSlotId(), slot.getId()); + } + + public Long getMemberId() { + return member.getId(); + } + + public Long getSlotId() { + return slot.getId(); } } diff --git a/src/main/java/roomescape/waiting/domain/WaitingLine.java b/src/main/java/roomescape/waiting/domain/WaitingLine.java index e09d2bd514..330d85266b 100644 --- a/src/main/java/roomescape/waiting/domain/WaitingLine.java +++ b/src/main/java/roomescape/waiting/domain/WaitingLine.java @@ -32,16 +32,20 @@ private void validateSameSlot(List waitings) { } public long orderOf(Waiting waiting) { + return orderOf(waiting.getId()); + } + + public long orderOf(Long waitingId) { for (int index = 0; index < waitings.size(); index++) { - if (hasSameId(waitings.get(index), waiting)) { + if (hasSameId(waitings.get(index), waitingId)) { return index + 1L; } } throw new IllegalArgumentException("대기열에 존재하지 않는 대기입니다."); } - private boolean hasSameId(Waiting source, Waiting target) { - return source.getId().equals(target.getId()); + private boolean hasSameId(Waiting source, Long targetId) { + return source.getId().equals(targetId); } public Optional first() { diff --git a/src/main/java/roomescape/waiting/domain/WaitingLines.java b/src/main/java/roomescape/waiting/domain/WaitingLines.java index eb996717ce..ed343f1931 100644 --- a/src/main/java/roomescape/waiting/domain/WaitingLines.java +++ b/src/main/java/roomescape/waiting/domain/WaitingLines.java @@ -21,10 +21,14 @@ public static WaitingLines of(List waitings) { } public long orderOf(Waiting waiting) { - WaitingLine waitingLine = linesBySlotId.get(waiting.getSlotId()); + return orderOf(waiting.getSlotId(), waiting.getId()); + } + + public long orderOf(Long slotId, Long waitingId) { + WaitingLine waitingLine = linesBySlotId.get(slotId); if (waitingLine == null) { throw new IllegalArgumentException("대기열에 존재하지 않는 대기입니다."); } - return waitingLine.orderOf(waiting); + return waitingLine.orderOf(waitingId); } } diff --git a/src/main/java/roomescape/waiting/domain/WaitingPromotionPolicy.java b/src/main/java/roomescape/waiting/domain/WaitingPromotionPolicy.java index 0bab275bba..473101fa8c 100644 --- a/src/main/java/roomescape/waiting/domain/WaitingPromotionPolicy.java +++ b/src/main/java/roomescape/waiting/domain/WaitingPromotionPolicy.java @@ -11,6 +11,6 @@ public Reservation promote(Waiting waiting, Slot slot) { if (!waiting.isFor(slot)) { throw new IllegalArgumentException("대기 슬롯과 예약 슬롯이 일치하지 않습니다."); } - return Reservation.create(waiting.getMemberId(), slot); + return Reservation.create(waiting.getMember(), slot); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 65da085be7..76b38f3566 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,15 @@ spring: sql: init: mode: always - schema-locations: classpath:schema.sql data-locations: classpath:data.sql + jpa: + show-sql: true + defer-datasource-initialization: true + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true auth: jwt: diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 67cccf4709..0000000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,58 +0,0 @@ -CREATE TABLE reservation_time -( - id BIGINT NOT NULL AUTO_INCREMENT, - start_at TIME NOT NULL, - PRIMARY KEY (id) -); - -CREATE TABLE theme -( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(50) NOT NULL, - description TEXT, - thumbnail_url VARCHAR(255) NOT NULL, - PRIMARY KEY (id) -); - -CREATE TABLE slot -( - id BIGINT NOT NULL AUTO_INCREMENT, - date DATE NOT NULL, - time_id BIGINT NOT NULL, - theme_id BIGINT NOT NULL, - PRIMARY KEY (id), - CONSTRAINT uk_slot_date_time_theme UNIQUE (date, time_id, theme_id), - FOREIGN KEY (theme_id) REFERENCES theme (id) ON DELETE RESTRICT, - FOREIGN KEY (time_id) REFERENCES reservation_time (id) ON DELETE RESTRICT -); - -CREATE TABLE member -( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, - role VARCHAR(50) NOT NULL, - PRIMARY KEY (id) -); - -CREATE TABLE waiting -( - id BIGINT NOT NULL AUTO_INCREMENT, - member_id BIGINT NOT NULL, - slot_id BIGINT NOT NULL, - CONSTRAINT uk_waiting_member_slot UNIQUE (member_id, slot_id), - FOREIGN KEY (member_id) REFERENCES member (id), - FOREIGN KEY (slot_id) REFERENCES slot (id), - PRIMARY KEY (id) -); - -CREATE TABLE reservation -( - id BIGINT NOT NULL AUTO_INCREMENT, - member_id BIGINT NOT NULL, - slot_id BIGINT NOT NULL, - PRIMARY KEY (id), - CONSTRAINT uk_reservation_slot UNIQUE (slot_id), - FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE RESTRICT, - FOREIGN KEY (slot_id) REFERENCES slot (id) ON DELETE RESTRICT -); diff --git a/src/test/java/roomescape/TestFixtures.java b/src/test/java/roomescape/TestFixtures.java new file mode 100644 index 0000000000..40fed03099 --- /dev/null +++ b/src/test/java/roomescape/TestFixtures.java @@ -0,0 +1,38 @@ +package roomescape; + +import java.time.LocalDate; +import java.time.LocalTime; +import roomescape.member.domain.Member; +import roomescape.member.domain.Role; +import roomescape.reservation.domain.Reservation; +import roomescape.reservationtime.domain.ReservationTime; +import roomescape.slot.domain.Slot; +import roomescape.theme.domain.Theme; +import roomescape.waiting.domain.Waiting; + +public final class TestFixtures { + + private TestFixtures() { + } + + public static Member member(long id) { + return new Member(id, "member" + id, "password", Role.USER); + } + + public static Slot slot(long id) { + return Slot.of( + id, + LocalDate.of(2026, 5, 5), + new ReservationTime(1L, LocalTime.of(10, 0)), + new Theme(1L, "theme", "description", "thumbnail") + ); + } + + public static Reservation reservation(Long id, Long memberId, Slot slot) { + return Reservation.of(id, member(memberId), slot); + } + + public static Waiting waiting(Long id, Long memberId, Long slotId) { + return Waiting.of(id, member(memberId), slot(slotId)); + } +} diff --git a/src/test/java/roomescape/reservation/JdbcReservationRepositoryTest.java b/src/test/java/roomescape/reservation/JpaReservationRepositoryTest.java similarity index 84% rename from src/test/java/roomescape/reservation/JdbcReservationRepositoryTest.java rename to src/test/java/roomescape/reservation/JpaReservationRepositoryTest.java index 609d25d83f..722ac3b803 100644 --- a/src/test/java/roomescape/reservation/JdbcReservationRepositoryTest.java +++ b/src/test/java/roomescape/reservation/JpaReservationRepositoryTest.java @@ -10,30 +10,30 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -import roomescape.reservation.adapter.out.persistence.JdbcReservationRepository; +import roomescape.reservation.adapter.out.persistence.JpaReservationRepository; import roomescape.reservation.application.port.out.projection.ReservationDetailProjection; import roomescape.reservation.domain.Reservation; import roomescape.reservationtime.domain.ReservationTime; import roomescape.slot.domain.Slot; import roomescape.theme.domain.Theme; -@JdbcTest +@DataJpaTest @ActiveProfiles("test") -@Import(JdbcReservationRepository.class) -class JdbcReservationRepositoryTest { +@Import(JpaReservationRepository.class) +class JpaReservationRepositoryTest { private static final long MEMBER_ID = 1L; @Autowired - private JdbcReservationRepository reservationRepository; + private JpaReservationRepository reservationRepository; @Test @DisplayName("예약을 저장할 수 있다.") void saves_reservation_successfully() { - Reservation reservation = Reservation.create(MEMBER_ID, slot(4L)); + Reservation reservation = Reservation.create(roomescape.TestFixtures.member(MEMBER_ID), slot(4L)); Reservation savedReservation = reservationRepository.save(reservation); @@ -57,7 +57,7 @@ private Slot slot(long slotId) { @Test @DisplayName("전체 예약 상세를 조회할 수 있다.") void finds_all_reservation_details_successfully() { - Reservation reservation = Reservation.create(MEMBER_ID, slot(4L)); + Reservation reservation = Reservation.create(roomescape.TestFixtures.member(MEMBER_ID), slot(4L)); Reservation savedReservation = reservationRepository.save(reservation); List reservations = reservationRepository.findAll(); @@ -70,7 +70,7 @@ void finds_all_reservation_details_successfully() { @Test @DisplayName("예약을 삭제할 수 있다.") void deletes_reservation_successfully() { - Reservation reservation = Reservation.create(MEMBER_ID, slot(4L)); + Reservation reservation = Reservation.create(roomescape.TestFixtures.member(MEMBER_ID), slot(4L)); Reservation savedReservation = reservationRepository.save(reservation); reservationRepository.deleteById(savedReservation.getId()); diff --git a/src/test/java/roomescape/reservation/ReservationApiIntegrationTest.java b/src/test/java/roomescape/reservation/ReservationApiIntegrationTest.java index 8adca53f29..fdb532facc 100644 --- a/src/test/java/roomescape/reservation/ReservationApiIntegrationTest.java +++ b/src/test/java/roomescape/reservation/ReservationApiIntegrationTest.java @@ -68,6 +68,21 @@ void deletes_my_reservation_and_returns_my_reservation_list() { .body("data.size()", is(3)); } + @Test + @DisplayName("미션 원문 경로로 나의 예약 목록을 조회할 수 있다.") + void finds_my_reservations_with_mission_path_successfully() { + String accessToken = loginUserToken(); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/reservations-mine") + .then().log().all() + .statusCode(200) + .body("success", is(true)) + .body("data.size()", is(4)) + .body("data[0].status", is("RESERVED")); + } + @Test @DisplayName("양수가 아닌 예약 id로 삭제를 요청하면 400을 응답한다.") void non_positive_reservation_id_delete_request_returns_bad_request() { @@ -112,6 +127,39 @@ void my_reservation_list_includes_waitings() { .body("data[0].waitingOrder", is(1)); } + @Test + @DisplayName("나의 예약 목록에서 예약과 대기를 함께 조회한다.") + void my_reservation_list_includes_reserved_and_waiting_items() { + String accessToken = loginWaitingUserToken(); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(reservationRequest()) + .when().post("/api/user/reservations") + .then().log().all() + .statusCode(201); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(waitingRequest()) + .when().post("/api/user/waitings") + .then().log().all() + .statusCode(201); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .when().get("/api/user/reservations/me") + .then().log().all() + .statusCode(200) + .body("success", is(true)) + .body("data.size()", is(2)) + .body("data[0].status", is("RESERVED")) + .body("data[1].status", is("WAITING")) + .body("data[1].waitingOrder", is(1)); + } + private Map waitingRequest() { Map waiting = new HashMap<>(); waiting.put("date", "2026-05-05"); diff --git a/src/test/java/roomescape/reservation/ReservationServiceTest.java b/src/test/java/roomescape/reservation/ReservationServiceTest.java index 570ff69f50..dd8ce63aa5 100644 --- a/src/test/java/roomescape/reservation/ReservationServiceTest.java +++ b/src/test/java/roomescape/reservation/ReservationServiceTest.java @@ -25,6 +25,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import roomescape.exception.EscapeRoomException; +import roomescape.member.application.port.out.MemberRepository; import roomescape.reservation.application.ReservationService; import roomescape.reservation.application.dto.response.ReservationDetailFindResponse; import roomescape.reservation.application.port.out.ReservationRepository; @@ -50,6 +51,8 @@ class ReservationServiceTest { @Mock private ReservationRepository reservationRepository; @Mock + private MemberRepository memberRepository; + @Mock private SlotAssembler slotAssembler; @Mock private WaitingRepository waitingRepository; @@ -61,6 +64,7 @@ class ReservationServiceTest { void setUp() { reservationService = new ReservationService( reservationRepository, + memberRepository, waitingRepository, slotAssembler, waitingPromotionPolicy, @@ -79,10 +83,10 @@ void calculates_waiting_order_with_bulk_waiting_line_lookup_for_my_reservations( .thenReturn(List.of(firstWaitingDetail, secondWaitingDetail)); when(waitingRepository.findAllBySlotIds(List.of(10L, 20L))) .thenReturn(List.of( - Waiting.of(11L, MEMBER_ID, 10L), - Waiting.of(9L, OTHER_MEMBER_ID, 10L), - Waiting.of(22L, MEMBER_ID, 20L), - Waiting.of(21L, OTHER_MEMBER_ID, 20L) + roomescape.TestFixtures.waiting(11L, MEMBER_ID, 10L), + roomescape.TestFixtures.waiting(9L, OTHER_MEMBER_ID, 10L), + roomescape.TestFixtures.waiting(22L, MEMBER_ID, 20L), + roomescape.TestFixtures.waiting(21L, OTHER_MEMBER_ID, 20L) )); List responses = reservationService.findMyReservations(MEMBER_ID); @@ -132,7 +136,7 @@ private Reservation reservation( LocalTime startAt, Long slotId ) { - return Reservation.of(reservationId, memberId, slot(slotId, date, themeId, timeId, startAt)); + return roomescape.TestFixtures.reservation(reservationId, memberId, slot(slotId, date, themeId, timeId, startAt)); } private Slot slot(Long slotId, LocalDate date, Long themeId, Long timeId, LocalTime startAt) { @@ -180,9 +184,9 @@ void deleting_reservation_promotes_first_waiting_in_same_slot() { Reservation oldReservation = reservation( reservationId, MEMBER_ID, LocalDate.of(2026, 6, 1), 1L, 1L, LocalTime.of(10, 0), 10L ); - Waiting firstWaiting = Waiting.of(1L, 2L, oldReservation.getSlotId()); - Waiting secondWaiting = Waiting.of(2L, 3L, oldReservation.getSlotId()); - Reservation promotedReservation = Reservation.create(firstWaiting.getMemberId(), oldReservation.getSlot()); + Waiting firstWaiting = roomescape.TestFixtures.waiting(1L, 2L, oldReservation.getSlotId()); + Waiting secondWaiting = roomescape.TestFixtures.waiting(2L, 3L, oldReservation.getSlotId()); + Reservation promotedReservation = Reservation.create(firstWaiting.getMember(), oldReservation.getSlot()); when(reservationRepository.findById(reservationId)).thenReturn(Optional.of(oldReservation)); when(waitingRepository.findAllBySlotIdOrderByIdForUpdate(oldReservation.getSlotId())) @@ -207,8 +211,8 @@ void failed_promotion_reservation_save_keeps_promoted_waiting() { Reservation oldReservation = reservation( reservationId, MEMBER_ID, LocalDate.of(2026, 6, 1), 1L, 1L, LocalTime.of(10, 0), 10L ); - Waiting firstWaiting = Waiting.of(1L, 2L, oldReservation.getSlotId()); - Reservation promotedReservation = Reservation.create(firstWaiting.getMemberId(), oldReservation.getSlot()); + Waiting firstWaiting = roomescape.TestFixtures.waiting(1L, 2L, oldReservation.getSlotId()); + Reservation promotedReservation = Reservation.create(firstWaiting.getMember(), oldReservation.getSlot()); when(reservationRepository.findById(reservationId)).thenReturn(Optional.of(oldReservation)); when(waitingRepository.findAllBySlotIdOrderByIdForUpdate(oldReservation.getSlotId())) diff --git a/src/test/java/roomescape/reservation/ReservationTest.java b/src/test/java/roomescape/reservation/ReservationTest.java index 136e5ade90..7baf7e544d 100644 --- a/src/test/java/roomescape/reservation/ReservationTest.java +++ b/src/test/java/roomescape/reservation/ReservationTest.java @@ -19,7 +19,7 @@ public class ReservationTest { @Test @DisplayName("예약자가 같으면 본인 예약이다.") void same_member_is_owner_of_reservation() { - Reservation reservation = Reservation.of(1L, 1L, slot()); + Reservation reservation = roomescape.TestFixtures.reservation(1L, 1L, slot()); assertThat(reservation.isOwnedBy(1L)).isTrue(); } @@ -36,7 +36,7 @@ private Slot slot() { @Test @DisplayName("예약자가 다르면 본인 예약이 아니다.") void different_member_is_not_owner_of_reservation() { - Reservation reservation = Reservation.of(1L, 1L, slot()); + Reservation reservation = roomescape.TestFixtures.reservation(1L, 1L, slot()); assertThat(reservation.isOwnedBy(2L)).isFalse(); } @@ -44,7 +44,7 @@ void different_member_is_not_owner_of_reservation() { @Test @DisplayName("예약자가 다르면 소유자 검증에 실패한다.") void different_member_fails_reservation_owner_validation() { - Reservation reservation = Reservation.of(1L, 1L, slot()); + Reservation reservation = roomescape.TestFixtures.reservation(1L, 1L, slot()); assertThatThrownBy(() -> reservation.validateOwnedBy(2L)) .isInstanceOf(EscapeRoomException.class); @@ -53,7 +53,7 @@ void different_member_fails_reservation_owner_validation() { @Test @DisplayName("예약 시간이 과거이면 검증에 실패한다.") void past_reservation_time_fails_validation() { - Reservation reservation = Reservation.of(1L, 1L, slot()); + Reservation reservation = roomescape.TestFixtures.reservation(1L, 1L, slot()); assertThatThrownBy(() -> reservation.validateNotPast(LocalDateTime.of(2026, 5, 5, 10, 1))) .isInstanceOf(EscapeRoomException.class); diff --git a/src/test/java/roomescape/reservation/ReservationTransactionIntegrationTest.java b/src/test/java/roomescape/reservation/ReservationTransactionIntegrationTest.java index c014281bb3..006f2ceb88 100644 --- a/src/test/java/roomescape/reservation/ReservationTransactionIntegrationTest.java +++ b/src/test/java/roomescape/reservation/ReservationTransactionIntegrationTest.java @@ -98,7 +98,7 @@ void reservation_cancel_first_promotes_waiting_and_late_waiting_cancel_fails() t Waiting waiting = invocation.getArgument(0); Slot slot = invocation.getArgument(1); - return Reservation.create(waiting.getMemberId(), slot); + return Reservation.create(waiting.getMember(), slot); }); ExecutorService executorService = Executors.newFixedThreadPool(2); diff --git a/src/test/java/roomescape/reservationtime/JdbcReservationTimeRepositoryTest.java b/src/test/java/roomescape/reservationtime/JpaReservationTimeRepositoryTest.java similarity index 91% rename from src/test/java/roomescape/reservationtime/JdbcReservationTimeRepositoryTest.java rename to src/test/java/roomescape/reservationtime/JpaReservationTimeRepositoryTest.java index 3928fae8b9..60a6b15a0c 100644 --- a/src/test/java/roomescape/reservationtime/JdbcReservationTimeRepositoryTest.java +++ b/src/test/java/roomescape/reservationtime/JpaReservationTimeRepositoryTest.java @@ -8,18 +8,18 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -import roomescape.reservationtime.adapter.out.persistence.JdbcReservationTimeRepository; +import roomescape.reservationtime.adapter.out.persistence.JpaReservationTimeRepository; import roomescape.reservationtime.domain.ReservationTime; -@JdbcTest +@DataJpaTest @ActiveProfiles("test") -@Import(JdbcReservationTimeRepository.class) -public class JdbcReservationTimeRepositoryTest { +@Import(JpaReservationTimeRepository.class) +public class JpaReservationTimeRepositoryTest { @Autowired - private JdbcReservationTimeRepository repository; + private JpaReservationTimeRepository repository; @Test @DisplayName("시간을 저장할 수 있다.") diff --git a/src/test/java/roomescape/slot/JdbcSlotRepositoryTest.java b/src/test/java/roomescape/slot/JpaSlotRepositoryTest.java similarity index 93% rename from src/test/java/roomescape/slot/JdbcSlotRepositoryTest.java rename to src/test/java/roomescape/slot/JpaSlotRepositoryTest.java index 6711d1677a..e0994a6557 100644 --- a/src/test/java/roomescape/slot/JdbcSlotRepositoryTest.java +++ b/src/test/java/roomescape/slot/JpaSlotRepositoryTest.java @@ -9,21 +9,21 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import roomescape.reservationtime.domain.ReservationTime; -import roomescape.slot.adapter.out.persistence.JdbcSlotRepository; +import roomescape.slot.adapter.out.persistence.JpaSlotRepository; import roomescape.slot.domain.Slot; import roomescape.theme.domain.Theme; -@JdbcTest +@DataJpaTest @ActiveProfiles("test") -@Import(JdbcSlotRepository.class) -class JdbcSlotRepositoryTest { +@Import(JpaSlotRepository.class) +class JpaSlotRepositoryTest { @Autowired - private JdbcSlotRepository repository; + private JpaSlotRepository repository; @Test @DisplayName("슬롯을 저장할 수 있다.") diff --git a/src/test/java/roomescape/theme/JdbcThemeRepositoryTest.java b/src/test/java/roomescape/theme/JpaThemeRepositoryTest.java similarity index 93% rename from src/test/java/roomescape/theme/JdbcThemeRepositoryTest.java rename to src/test/java/roomescape/theme/JpaThemeRepositoryTest.java index b18c191ae8..12365135dc 100644 --- a/src/test/java/roomescape/theme/JdbcThemeRepositoryTest.java +++ b/src/test/java/roomescape/theme/JpaThemeRepositoryTest.java @@ -7,18 +7,18 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -import roomescape.theme.adapter.out.persistence.JdbcThemeRepository; +import roomescape.theme.adapter.out.persistence.JpaThemeRepository; import roomescape.theme.domain.Theme; -@JdbcTest +@DataJpaTest @ActiveProfiles("test") -@Import(JdbcThemeRepository.class) -class JdbcThemeRepositoryTest { +@Import(JpaThemeRepository.class) +class JpaThemeRepositoryTest { @Autowired - private JdbcThemeRepository repository; + private JpaThemeRepository repository; @Test @DisplayName("테마를 저장할 수 있다.") diff --git a/src/test/java/roomescape/waiting/JdbcWaitingRepositoryTest.java b/src/test/java/roomescape/waiting/JpaWaitingRepositoryTest.java similarity index 50% rename from src/test/java/roomescape/waiting/JdbcWaitingRepositoryTest.java rename to src/test/java/roomescape/waiting/JpaWaitingRepositoryTest.java index 71fe3e1e85..5e5eb2aeb0 100644 --- a/src/test/java/roomescape/waiting/JdbcWaitingRepositoryTest.java +++ b/src/test/java/roomescape/waiting/JpaWaitingRepositoryTest.java @@ -1,6 +1,7 @@ package roomescape.waiting; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.time.LocalDate; import java.util.List; @@ -8,27 +9,28 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -import roomescape.waiting.adapter.out.persistence.JdbcWaitingRepository; +import roomescape.waiting.adapter.out.persistence.JpaWaitingRepository; +import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; import roomescape.waiting.domain.Waiting; -@JdbcTest +@DataJpaTest @ActiveProfiles("test") -@Import(JdbcWaitingRepository.class) -class JdbcWaitingRepositoryTest { +@Import(JpaWaitingRepository.class) +class JpaWaitingRepositoryTest { private static final long MEMBER_ID = 1L; private static final long SLOT_ID = 1L; @Autowired - private JdbcWaitingRepository waitingRepository; + private JpaWaitingRepository waitingRepository; @Test @DisplayName("대기 저장에 성공한다.") void saves_waiting_successfully() { - Waiting waiting = Waiting.create(MEMBER_ID, SLOT_ID); + Waiting waiting = Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID)); Waiting savedWaiting = waitingRepository.save(waiting); @@ -40,7 +42,7 @@ void saves_waiting_successfully() { @Test @DisplayName("회원과 슬롯로 대기 존재 여부를 확인할 수 있다.") void checks_waiting_existence_by_member_and_slot() { - waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); boolean result = waitingRepository.existsBySlotIdAndMemberId(MEMBER_ID, SLOT_ID); @@ -55,10 +57,21 @@ void missing_waiting_by_member_and_slot_returns_false() { assertThat(result).isFalse(); } + @Test + @DisplayName("회원 id와 슬롯 id 조합으로 대기 존재 여부를 정확히 확인한다.") + void checks_waiting_existence_by_distinct_member_and_slot_ids() { + waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(3L), roomescape.TestFixtures.slot(SLOT_ID))); + + assertSoftly(softly -> { + softly.assertThat(waitingRepository.existsBySlotIdAndMemberId(3L, SLOT_ID)).isTrue(); + softly.assertThat(waitingRepository.existsBySlotIdAndMemberId(MEMBER_ID, 3L)).isFalse(); + }); + } + @Test @DisplayName("대기 id로 대기를 조회할 수 있다.") void finds_waiting_by_id_successfully() { - Waiting savedWaiting = waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + Waiting savedWaiting = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); Waiting result = waitingRepository.findById(savedWaiting.getId()) .orElseThrow(); @@ -71,7 +84,7 @@ void finds_waiting_by_id_successfully() { @Test @DisplayName("대기 id로 락을 걸고 대기를 조회할 수 있다.") void finds_waiting_by_id_with_lock_successfully() { - Waiting savedWaiting = waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + Waiting savedWaiting = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); Waiting result = waitingRepository.findByIdForUpdate(savedWaiting.getId()) .orElseThrow(); @@ -84,7 +97,7 @@ void finds_waiting_by_id_with_lock_successfully() { @Test @DisplayName("대기 id로 대기를 삭제할 수 있다.") void deletes_waiting_by_id_successfully() { - Waiting savedWaiting = waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + Waiting savedWaiting = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); waitingRepository.deleteById(savedWaiting.getId()); @@ -94,10 +107,10 @@ void deletes_waiting_by_id_successfully() { @Test @DisplayName("특정 슬롯의 대기 목록을 신청 순서대로 조회할 수 있다.") void finds_waitings_by_slot_id_in_request_order() { - Waiting first = waitingRepository.save(Waiting.create(3L, SLOT_ID)); - Waiting second = waitingRepository.save(Waiting.create(2L, SLOT_ID)); - Waiting otherSlotWaiting = waitingRepository.save(Waiting.create(4L, 2L)); - Waiting third = waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + Waiting first = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(3L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting second = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(2L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting otherSlotWaiting = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(4L), roomescape.TestFixtures.slot(2L))); + Waiting third = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); List result = waitingRepository.findAllBySlotIdOrderById(SLOT_ID); @@ -110,10 +123,10 @@ void finds_waitings_by_slot_id_in_request_order() { @Test @DisplayName("특정 슬롯의 대기 목록을 락을 걸고 신청 순서대로 조회할 수 있다.") void finds_waitings_by_slot_id_with_lock_in_request_order() { - Waiting first = waitingRepository.save(Waiting.create(3L, SLOT_ID)); - Waiting second = waitingRepository.save(Waiting.create(2L, SLOT_ID)); - Waiting otherSlotWaiting = waitingRepository.save(Waiting.create(4L, 2L)); - Waiting third = waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + Waiting first = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(3L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting second = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(2L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting otherSlotWaiting = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(4L), roomescape.TestFixtures.slot(2L))); + Waiting third = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); List result = waitingRepository.findAllBySlotIdOrderByIdForUpdate(SLOT_ID); @@ -126,10 +139,10 @@ void finds_waitings_by_slot_id_with_lock_in_request_order() { @Test @DisplayName("여러 슬롯의 대기 목록을 한 번에 조회할 수 있다.") void finds_waitings_for_multiple_slots_at_once() { - Waiting firstSlotFirst = waitingRepository.save(Waiting.create(3L, SLOT_ID)); - Waiting firstSlotSecond = waitingRepository.save(Waiting.create(2L, SLOT_ID)); - Waiting secondSlotFirst = waitingRepository.save(Waiting.create(4L, 2L)); - Waiting firstSlotThird = waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + Waiting firstSlotFirst = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(3L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting firstSlotSecond = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(2L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting secondSlotFirst = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(4L), roomescape.TestFixtures.slot(2L))); + Waiting firstSlotThird = waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); List result = waitingRepository.findAllBySlotIds(List.of(2L, SLOT_ID)); @@ -142,10 +155,46 @@ void finds_waitings_for_multiple_slots_at_once() { ); } + @Test + @DisplayName("빈 슬롯 id 목록으로 대기를 조회하면 빈 목록을 반환한다.") + void empty_slot_ids_return_empty_waitings() { + waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(3L), roomescape.TestFixtures.slot(SLOT_ID))); + + List result = waitingRepository.findAllBySlotIds(List.of()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("특정 회원의 대기 상세 목록을 대기 신청 순서대로 조회할 수 있다.") + void finds_all_waiting_details_by_member_id_in_request_order() { + Waiting otherMemberWaiting = waitingRepository.save( + Waiting.create(roomescape.TestFixtures.member(2L), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting first = waitingRepository.save( + Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); + Waiting second = waitingRepository.save( + Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(2L))); + + List result = waitingRepository.findAllWaitingDetailsByMemberId(MEMBER_ID); + + assertThat(result).extracting(WaitingDetailProjection::id) + .containsExactly(first.getId(), second.getId()); + assertThat(result).extracting(WaitingDetailProjection::id) + .doesNotContain(otherMemberWaiting.getId()); + assertSoftly(softly -> { + softly.assertThat(result.getFirst().slotId()).isEqualTo(SLOT_ID); + softly.assertThat(result.getFirst().memberName()).isEqualTo("a"); + softly.assertThat(result.getFirst().date()).isEqualTo(LocalDate.parse("2026-05-05")); + softly.assertThat(result.getFirst().themeId()).isEqualTo(1L); + softly.assertThat(result.getFirst().themeName()).isEqualTo("세기의 도둑"); + softly.assertThat(result.getFirst().timeId()).isEqualTo(1L); + }); + } + @Test @DisplayName("날짜와 테마로 대기가 있는 시간 id를 조회할 수 있다.") void finds_waiting_time_ids_by_date_and_theme() { - waitingRepository.save(Waiting.create(MEMBER_ID, SLOT_ID)); + waitingRepository.save(Waiting.create(roomescape.TestFixtures.member(MEMBER_ID), roomescape.TestFixtures.slot(SLOT_ID))); Set result = waitingRepository.findTimeIdByDateAndThemeId(LocalDate.parse("2026-05-05"), 1L); diff --git a/src/test/java/roomescape/waiting/WaitingApiIntegrationTest.java b/src/test/java/roomescape/waiting/WaitingApiIntegrationTest.java index 5556742381..8a7386d4bd 100644 --- a/src/test/java/roomescape/waiting/WaitingApiIntegrationTest.java +++ b/src/test/java/roomescape/waiting/WaitingApiIntegrationTest.java @@ -33,6 +33,24 @@ void creates_waiting_for_reserved_slot_successfully() { .body("data.waitingOrder", is(1)); } + @Test + @DisplayName("미션 원문 경로로도 이미 예약된 슬롯에 대기를 신청할 수 있다.") + void creates_waiting_with_mission_path_successfully() { + String accessToken = loginWaitingUserToken(); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(waitingRequest()) + .when().post("/waitings") + .then().log().all() + .statusCode(201) + .body("success", is(true)) + .body("data.memberId", is(2)) + .body("data.slotId", is(1)) + .body("data.waitingOrder", is(1)); + } + private Map waitingRequest() { return waitingRequest(LocalDate.of(2026, 5, 5), 1L, 1L); } @@ -154,6 +172,84 @@ void cancels_waiting_successfully() { .statusCode(204); } + @Test + @DisplayName("대기를 취소하면 목록에서 사라지고 남은 대기 순번이 재계산된다.") + void canceling_waiting_removes_it_and_reorders_remaining_waitings() { + String firstAccessToken = loginWaitingUserToken(); + String secondAccessToken = loginOtherUserToken(); + + Integer firstWaitingId = createWaiting(firstAccessToken); + createWaiting(secondAccessToken); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + firstAccessToken) + .contentType(ContentType.JSON) + .pathParam("id", firstWaitingId) + .when().delete("/api/user/waitings/{id}") + .then().log().all() + .statusCode(204); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + firstAccessToken) + .when().get("/api/user/reservations/me") + .then().log().all() + .statusCode(200) + .body("success", is(true)) + .body("data.size()", is(0)); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + secondAccessToken) + .when().get("/api/user/reservations/me") + .then().log().all() + .statusCode(200) + .body("success", is(true)) + .body("data.size()", is(1)) + .body("data[0].status", is("WAITING")) + .body("data[0].waitingOrder", is(1)); + } + + @Test + @DisplayName("매니저는 대기 목록을 조회할 수 있다.") + void manager_finds_waiting_list_successfully() { + createWaiting(loginWaitingUserToken()); + createWaiting(loginOtherUserToken()); + String managerAccessToken = loginManagerToken(); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + managerAccessToken) + .when().get("/api/manager/waitings") + .then().log().all() + .statusCode(200) + .body("success", is(true)) + .body("data.size()", is(2)) + .body("data[0].waitingOrder", is(1)) + .body("data[1].waitingOrder", is(2)); + } + + @Test + @DisplayName("매니저는 대기를 취소할 수 있다.") + void manager_cancels_waiting_successfully() { + String waitingUserToken = loginWaitingUserToken(); + Integer waitingId = createWaiting(waitingUserToken); + String managerAccessToken = loginManagerToken(); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + managerAccessToken) + .contentType(ContentType.JSON) + .pathParam("id", waitingId) + .when().delete("/api/manager/waitings/{id}") + .then().log().all() + .statusCode(204); + + RestAssured.given().log().all() + .header("Authorization", "Bearer " + waitingUserToken) + .when().get("/api/user/reservations/me") + .then().log().all() + .statusCode(200) + .body("success", is(true)) + .body("data.size()", is(0)); + } + @Test @DisplayName("없는 대기를 취소하면 404를 응답한다.") void canceling_missing_waiting_returns_not_found() { diff --git a/src/test/java/roomescape/waiting/WaitingLineTest.java b/src/test/java/roomescape/waiting/WaitingLineTest.java index 9aa2ad048c..ec02170bf5 100644 --- a/src/test/java/roomescape/waiting/WaitingLineTest.java +++ b/src/test/java/roomescape/waiting/WaitingLineTest.java @@ -14,8 +14,8 @@ public class WaitingLineTest { @Test @DisplayName("가장 먼저 신청한 대기를 첫 번째 대기로 반환한다.") void returns_earliest_waiting_as_first() { - Waiting first = Waiting.of(1L, 1L, 1L); - Waiting second = Waiting.of(2L, 2L, 1L); + Waiting first = roomescape.TestFixtures.waiting(1L, 1L, 1L); + Waiting second = roomescape.TestFixtures.waiting(2L, 2L, 1L); WaitingLine waitingLine = WaitingLine.of(List.of(first, second)); assertThat(waitingLine.first()).contains(first); @@ -24,8 +24,8 @@ void returns_earliest_waiting_as_first() { @Test @DisplayName("대기 순번은 신청 순서 기준으로 계산한다.") void calculates_waiting_order_by_request_sequence() { - Waiting first = Waiting.of(1L, 1L, 1L); - Waiting second = Waiting.of(2L, 2L, 1L); + Waiting first = roomescape.TestFixtures.waiting(1L, 1L, 1L); + Waiting second = roomescape.TestFixtures.waiting(2L, 2L, 1L); WaitingLine waitingLine = WaitingLine.of(List.of(first, second)); assertThat(waitingLine.orderOf(second)).isEqualTo(2); @@ -42,8 +42,8 @@ void empty_waiting_line_returns_no_first_waiting() { @Test @DisplayName("서로 다른 슬롯의 대기로 대기열을 만들 수 없다.") void waitings_from_different_slots_cannot_create_waiting_line() { - Waiting first = Waiting.of(1L, 1L, 1L); - Waiting second = Waiting.of(2L, 2L, 2L); + Waiting first = roomescape.TestFixtures.waiting(1L, 1L, 1L); + Waiting second = roomescape.TestFixtures.waiting(2L, 2L, 2L); assertThatThrownBy(() -> WaitingLine.of(List.of(first, second))) .isInstanceOf(IllegalArgumentException.class); diff --git a/src/test/java/roomescape/waiting/WaitingLinesTest.java b/src/test/java/roomescape/waiting/WaitingLinesTest.java index c537e97631..1edcbf7286 100644 --- a/src/test/java/roomescape/waiting/WaitingLinesTest.java +++ b/src/test/java/roomescape/waiting/WaitingLinesTest.java @@ -14,10 +14,10 @@ class WaitingLinesTest { @Test @DisplayName("여러 슬롯의 대기열에서 특정 대기의 순번을 계산한다.") void calculates_order_of_waiting_across_multiple_waiting_lines() { - Waiting firstSlotFirst = Waiting.of(1L, 1L, 1L); - Waiting firstSlotSecond = Waiting.of(2L, 2L, 1L); - Waiting secondSlotFirst = Waiting.of(3L, 3L, 2L); - Waiting secondSlotSecond = Waiting.of(4L, 4L, 2L); + Waiting firstSlotFirst = roomescape.TestFixtures.waiting(1L, 1L, 1L); + Waiting firstSlotSecond = roomescape.TestFixtures.waiting(2L, 2L, 1L); + Waiting secondSlotFirst = roomescape.TestFixtures.waiting(3L, 3L, 2L); + Waiting secondSlotSecond = roomescape.TestFixtures.waiting(4L, 4L, 2L); WaitingLines waitingLines = WaitingLines.of(List.of( firstSlotFirst, firstSlotSecond, @@ -32,9 +32,9 @@ void calculates_order_of_waiting_across_multiple_waiting_lines() { @Test @DisplayName("대기열에 없는 대기의 순번은 계산할 수 없다.") void waiting_not_in_line_cannot_have_order() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); WaitingLines waitingLines = WaitingLines.of(List.of(waiting)); - Waiting unknownWaiting = Waiting.of(2L, 2L, 2L); + Waiting unknownWaiting = roomescape.TestFixtures.waiting(2L, 2L, 2L); assertThatThrownBy(() -> waitingLines.orderOf(unknownWaiting)) .isInstanceOf(IllegalArgumentException.class); diff --git a/src/test/java/roomescape/waiting/WaitingPromotionPolicyTest.java b/src/test/java/roomescape/waiting/WaitingPromotionPolicyTest.java index 2e3c4ded6c..4f9aa30883 100644 --- a/src/test/java/roomescape/waiting/WaitingPromotionPolicyTest.java +++ b/src/test/java/roomescape/waiting/WaitingPromotionPolicyTest.java @@ -21,7 +21,7 @@ class WaitingPromotionPolicyTest { @Test @DisplayName("대기를 같은 슬롯의 예약으로 전환한다.") void promotes_waiting_to_reservation_in_same_slot() { - Waiting waiting = Waiting.of(1L, 2L, 10L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 2L, 10L); Slot slot = slot(10L); Reservation reservation = policy.promote(waiting, slot); @@ -43,7 +43,7 @@ private Slot slot(long slotId) { @Test @DisplayName("대기 슬롯과 예약 슬롯이 다르면 전환에 실패한다.") void promotion_fails_for_different_waiting_and_reservation_slots() { - Waiting waiting = Waiting.of(1L, 2L, 10L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 2L, 10L); Slot otherSlot = slot(20L); assertThatThrownBy(() -> policy.promote(waiting, otherSlot)) diff --git a/src/test/java/roomescape/waiting/WaitingServiceTest.java b/src/test/java/roomescape/waiting/WaitingServiceTest.java index 21de4807b9..4544985e7e 100644 --- a/src/test/java/roomescape/waiting/WaitingServiceTest.java +++ b/src/test/java/roomescape/waiting/WaitingServiceTest.java @@ -20,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import roomescape.exception.ErrorCode; import roomescape.exception.EscapeRoomException; +import roomescape.member.application.port.out.MemberRepository; import roomescape.reservation.application.port.out.ReservationRepository; import roomescape.reservationtime.domain.ReservationTime; import roomescape.slot.application.SlotAssembler; @@ -27,8 +28,10 @@ import roomescape.theme.domain.Theme; import roomescape.waiting.application.WaitingService; import roomescape.waiting.application.dto.request.WaitingRequest; +import roomescape.waiting.application.dto.response.WaitingDetailFindResponse; import roomescape.waiting.application.dto.response.WaitingResponse; import roomescape.waiting.application.port.out.WaitingRepository; +import roomescape.waiting.application.port.out.projection.WaitingDetailProjection; import roomescape.waiting.domain.Waiting; @ExtendWith(MockitoExtension.class) @@ -39,6 +42,9 @@ class WaitingServiceTest { @Mock private SlotAssembler slotAssembler; + @Mock + private MemberRepository memberRepository; + @Mock private WaitingRepository waitingRepository; @@ -53,10 +59,11 @@ class WaitingServiceTest { void saves_reservation_waiting_successfully() { WaitingRequest request = new WaitingRequest(LocalDate.of(2026, 5, 5), 1L, 1L); long slotId = 1L; - Waiting firstWaiting = Waiting.of(8L, 3L, slotId); - Waiting secondWaiting = Waiting.of(9L, 2L, slotId); - Waiting savedWaiting = Waiting.of(10L, MEMBER_ID, slotId); + Waiting firstWaiting = roomescape.TestFixtures.waiting(8L, 3L, slotId); + Waiting secondWaiting = roomescape.TestFixtures.waiting(9L, 2L, slotId); + Waiting savedWaiting = roomescape.TestFixtures.waiting(10L, MEMBER_ID, slotId); + givenMemberExists(); when(slotAssembler.assembleExisting(request.date(), request.timeId(), request.themeId())) .thenReturn(slot(slotId, request)); when(waitingRepository.existsBySlotIdAndMemberId(MEMBER_ID, slotId)) @@ -96,6 +103,7 @@ void duplicate_waiting_by_same_member_for_same_slot_throws_exception() { WaitingRequest request = new WaitingRequest(LocalDate.of(2026, 5, 5), 1L, 1L); long slotId = 1L; + givenMemberExists(); when(slotAssembler.assembleExisting(request.date(), request.timeId(), request.themeId())) .thenReturn(slot(slotId, request)); when(waitingRepository.existsBySlotIdAndMemberId(MEMBER_ID, slotId)) @@ -116,6 +124,7 @@ void member_with_existing_reservation_cannot_create_waiting() { WaitingRequest request = new WaitingRequest(LocalDate.of(2026, 5, 5), 1L, 1L); long slotId = 1L; + givenMemberExists(); when(slotAssembler.assembleExisting(request.date(), request.timeId(), request.themeId())) .thenReturn(slot(slotId, request)); when(reservationRepository.existsByMemberIdAndSlotId(MEMBER_ID, slotId)) @@ -136,6 +145,7 @@ void empty_slot_cannot_accept_waiting() { WaitingRequest request = new WaitingRequest(LocalDate.of(2026, 5, 5), 4L, 4L); long slotId = 4L; + givenMemberExists(); when(slotAssembler.assembleExisting(request.date(), request.timeId(), request.themeId())) .thenReturn(slot(slotId, request)); when(reservationRepository.existsByMemberIdAndSlotId(MEMBER_ID, slotId)) @@ -158,8 +168,9 @@ void empty_slot_cannot_accept_waiting() { void reserved_slot_accepts_first_waiting() { WaitingRequest request = new WaitingRequest(LocalDate.of(2026, 5, 5), 1L, 1L); long slotId = 1L; - Waiting savedWaiting = Waiting.of(10L, MEMBER_ID, slotId); + Waiting savedWaiting = roomescape.TestFixtures.waiting(10L, MEMBER_ID, slotId); + givenMemberExists(); when(slotAssembler.assembleExisting(request.date(), request.timeId(), request.themeId())) .thenReturn(slot(slotId, request)); when(reservationRepository.existsByMemberIdAndSlotId(MEMBER_ID, slotId)) @@ -183,10 +194,15 @@ void reserved_slot_accepts_first_waiting() { verify(waitingRepository).save(any(Waiting.class)); } + private void givenMemberExists() { + when(memberRepository.findById(MEMBER_ID)) + .thenReturn(Optional.of(roomescape.TestFixtures.member(MEMBER_ID))); + } + @Test @DisplayName("본인의 예약 대기를 취소할 수 있다.") void member_cancels_own_waiting_successfully() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); when(waitingRepository.findByIdForUpdate(waiting.getId())).thenReturn(Optional.of(waiting)); assertThatCode(() -> waitingService.deleteByIdForUser(1L, 1L)) @@ -195,10 +211,22 @@ void member_cancels_own_waiting_successfully() { verify(waitingRepository).deleteById(1L); } + @Test + @DisplayName("매니저는 소유자 검증 없이 예약 대기를 취소할 수 있다.") + void manager_cancels_waiting_successfully() { + Waiting waiting = roomescape.TestFixtures.waiting(1L, 2L, 1L); + when(waitingRepository.findByIdForUpdate(waiting.getId())).thenReturn(Optional.of(waiting)); + + assertThatCode(() -> waitingService.deleteById(1L)) + .doesNotThrowAnyException(); + + verify(waitingRepository).deleteById(1L); + } + @Test @DisplayName("본인의 예약 대기가 아닌데 취소를 시도하면 예외가 발생한다.") void canceling_other_members_waiting_throws_exception() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); when(waitingRepository.findByIdForUpdate(waiting.getId())).thenReturn(Optional.of(waiting)); assertThatThrownBy(() -> waitingService.deleteByIdForUser(1L, 2L)) @@ -220,4 +248,42 @@ void canceling_missing_waiting_throws_exception() { verify(waitingRepository, never()).deleteById(999L); } + @Test + @DisplayName("대기 목록 조회 시 여러 슬롯의 대기열을 한 번에 조회해 순번을 계산한다.") + void finds_waiting_details_with_bulk_waiting_line_lookup() { + WaitingDetailProjection firstWaitingDetail = waitingDetail(11L, 10L); + WaitingDetailProjection secondWaitingDetail = waitingDetail(22L, 20L); + + when(waitingRepository.findAllWaitingDetails()) + .thenReturn(List.of(firstWaitingDetail, secondWaitingDetail)); + when(waitingRepository.findAllBySlotIds(List.of(10L, 20L))) + .thenReturn(List.of( + roomescape.TestFixtures.waiting(9L, 3L, 10L), + roomescape.TestFixtures.waiting(11L, MEMBER_ID, 10L), + roomescape.TestFixtures.waiting(21L, 3L, 20L), + roomescape.TestFixtures.waiting(22L, MEMBER_ID, 20L) + )); + + List responses = waitingService.findWaitingDetails(); + + assertThat(responses).extracting(WaitingDetailFindResponse::waitingOrder) + .containsExactly(2L, 2L); + verify(waitingRepository).findAllBySlotIds(List.of(10L, 20L)); + } + + private WaitingDetailProjection waitingDetail(Long waitingId, Long slotId) { + return new WaitingDetailProjection( + waitingId, + slotId, + "member", + LocalDate.of(2026, 6, 1), + 1L, + "theme", + "description", + "thumbnail", + 1L, + LocalTime.of(10, 0) + ); + } + } diff --git a/src/test/java/roomescape/waiting/WaitingTest.java b/src/test/java/roomescape/waiting/WaitingTest.java index b4741f05ab..0cf7039b09 100644 --- a/src/test/java/roomescape/waiting/WaitingTest.java +++ b/src/test/java/roomescape/waiting/WaitingTest.java @@ -18,7 +18,7 @@ public class WaitingTest { @Test @DisplayName("대기자가 같으면 본인 대기다.") void same_member_is_owner_of_waiting() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); assertThat(waiting.isOwnedBy(1L)).isTrue(); } @@ -26,7 +26,7 @@ void same_member_is_owner_of_waiting() { @Test @DisplayName("대기자가 다르면 본인 대기가 아니다.") void different_member_is_not_owner_of_waiting() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); assertThat(waiting.isOwnedBy(2L)).isFalse(); } @@ -34,7 +34,7 @@ void different_member_is_not_owner_of_waiting() { @Test @DisplayName("대기자가 다르면 소유자 검증에 실패한다.") void different_member_fails_waiting_owner_validation() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); assertThatThrownBy(() -> waiting.validateOwnedBy(2L)) .isInstanceOf(EscapeRoomException.class); @@ -43,7 +43,7 @@ void different_member_fails_waiting_owner_validation() { @Test @DisplayName("대기가 속한 슬롯과 전달받은 슬롯이 같으면 true를 반환한다.") void waiting_for_same_slot_returns_true() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); assertThat(waiting.isFor(slot(1L))).isTrue(); } @@ -60,7 +60,7 @@ private Slot slot(long slotId) { @Test @DisplayName("대기가 속한 슬롯과 전달받은 슬롯이 다르면 false를 반환한다.") void waiting_for_different_slot_returns_false() { - Waiting waiting = Waiting.of(1L, 1L, 1L); + Waiting waiting = roomescape.TestFixtures.waiting(1L, 1L, 1L); assertThat(waiting.isFor(slot(2L))).isFalse(); } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 48bbdcf10b..565a6aaf03 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -7,8 +7,15 @@ spring: sql: init: mode: always - schema-locations: classpath:schema.sql data-locations: classpath:test-data.sql + jpa: + show-sql: true + defer-datasource-initialization: true + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true auth: jwt: