Skip to content

Commit 3755ac2

Browse files
authored
Merge pull request #445
* test: Fake 객체 리팩토링 * refactor: 지난 날짜 및 시간 예약 불가 예외 처리 * refactor: 중복 예약 예외 처리 추가 * refactor: AdminReservationController 분리 * feat: 사용자 이름으로 예약 조회 기능 추가 * feat: 사용자 이름으로 예약 삭제 기능 추가 * refactor: 사용자 예약 조회 api url 수정 * refactor: 에러 포맷팅 누락 해결 * feat: 예약 수정 기능 구현 * feat: 추가된 기능 클라이언트 코드 추가 * refactor: 더미데이터 수정 * docs: 기능 명세 업데이트 * refactor: `@Valid`로 입력값 검증 * refactor: 불필요한 개행 삭제 * refactor: ErrorCode 네이밍 변경 * refactor: 가능 시간 조회 api url 수정 * refactor: errors 패키지 추가 * refactor: 엣지케이스 예외 추가 * refactor: 불필요한 `@Transactional` 어노테이션 삭제 * refactor: 불필요한 로깅 삭제 * refactor: 메서드 네이밍 변경 * refactor: 날짜와 시간 추출 로직 수정 * refactor: 시간 조회 시 theme id및 date id 검증 로직 추가 * refactor: 동일 자료형 검증 형태 통일 * test: 테마 테스트 추가 * refactor: 쿼리문 수정 * test: ReservationControllerTest 추가 * test: AdminReservationControllerTest 추가 * refactor: ReservationDate 어드민 컨트롤러 분리 * test: ReservationDate 컨트롤러 테스트 추가 * refactor: ReservationTime 어드민 기능 분리 * test: ReservationTime 컨트롤러 테스트 추가 * refactor: Theme 컨트롤러 관리자 기능 분리 * refactor: ReservationTime 패키지 수정 * test: Theme 컨트롤러 테스트 추가 * refactor: yml 필요한 필드 추가 * test: Theme e2e 테스트 추가 * test: ReservationTime e2e 테스트 추가 * test: ReservationDate e2e 테스트 추가 * test: Reservation e2e 테스트 추가 * refactor: 테스트 네이밍 컨벤션 통일 * refactor: 메서드 네이밍 수정 * feat: 테스트마다 고유한 db 이름 사용하도록 yml 추가
1 parent 9282ec9 commit 3755ac2

94 files changed

Lines changed: 4982 additions & 697 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
- 사용자는 이름, 날짜 ID, 시간 ID, 테마 ID를 입력해 예약을 생성한다.
88
- 예약 생성 시 이름이 비어 있으면 예외를 반환한다.
99
- 예약 생성 시 날짜, 시간, 테마 값이 누락되면 예외를 반환한다.
10+
- 사용자는 이름으로 본인의 예약 목록을 조회한다.
11+
- 사용자는 본인의 예약을 취소한다.
12+
- 사용자는 본인의 예약 날짜와 시간을 변경한다.
13+
- 예약 변경 시 날짜와 시간이 모두 누락되면 예외를 반환한다.
14+
- 예약 변경 시 존재하지 않는 날짜나 시간이 입력되면 예외를 반환한다.
15+
- 예약 변경 시 변경하려는 날짜와 시간이 현재보다 이전이면 예외를 반환한다.
16+
- 예약 변경 시 같은 날짜, 시간, 테마의 다른 예약이 이미 존재하면 예외를 반환한다.
1017
- 관리자는 전체 예약 목록을 조회한다.
1118
- 관리자는 예약 ID로 예약을 삭제한다.
1219

@@ -101,7 +108,7 @@
101108

102109
```json
103110
{
104-
"name": "쿠키",
111+
"name": "보예",
105112
"dateId": 1,
106113
"timeId": 2,
107114
"themeId": 3
@@ -113,7 +120,7 @@
113120
```json
114121
{
115122
"id": 29,
116-
"name": "쿠키",
123+
"name": "보예",
117124
"date": "2026-05-01",
118125
"time": "11:00",
119126
"theme": {
@@ -124,6 +131,55 @@
124131
}
125132
```
126133

134+
#### `GET /reservations?name={name}`
135+
136+
- 설명: 사용자 이름으로 예약 목록 조회
137+
- 응답 `200 OK`
138+
139+
```json
140+
{
141+
"name": "보예",
142+
"reservation": [
143+
{
144+
"reservationId": 29,
145+
"date": {
146+
"id": 1,
147+
"startWhen": "2026-05-01"
148+
},
149+
"time": {
150+
"id": 2,
151+
"startAt": "11:00"
152+
},
153+
"theme": {
154+
"id": 3,
155+
"name": "청춘물",
156+
"content": "학교 배경인 테마 입니다.",
157+
"url": "/themes/youth"
158+
}
159+
}
160+
]
161+
}
162+
```
163+
164+
#### `PATCH /reservations/{id}`
165+
166+
- 설명: 사용자 예약 날짜와 시간 변경
167+
- 요청 본문
168+
169+
```json
170+
{
171+
"startWhen": "2026-05-10",
172+
"startAt": "15:00"
173+
}
174+
```
175+
176+
- 응답 `204 No Content`
177+
178+
#### `DELETE /reservations/{id}`
179+
180+
- 설명: 사용자 예약 취소
181+
- 응답 `204 No Content`
182+
127183
#### `DELETE /admin/reservations/{id}`
128184

129185
- 설명: 예약 삭제
@@ -190,7 +246,7 @@
190246

191247
### 예약 시간
192248

193-
#### `GET /times?themeId={themeId}&dateId={dateId}`
249+
#### `GET /reservation-times/availability?themeId={themeId}&dateId={dateId}`
194250

195251
- 설명: 특정 테마와 날짜의 예약 가능 시간 조회
196252
- 응답 `200 OK`

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies {
2121
implementation 'org.springframework.boot:spring-boot-starter-web'
2222
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
2323
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
24+
implementation 'org.springframework.boot:spring-boot-starter-validation'
2425
compileOnly 'org.projectlombok:lombok'
2526
annotationProcessor 'org.projectlombok:lombok'
2627
runtimeOnly 'com.h2database:h2'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package roomescape.config;
2+
3+
import java.time.Clock;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
@Configuration
8+
public class TimeConfig {
9+
10+
@Bean
11+
public Clock clock() {
12+
return Clock.systemDefaultZone();
13+
}
14+
}

src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.sql.Statement;
55
import java.time.LocalDate;
66
import java.util.List;
7+
import java.util.Optional;
78
import lombok.RequiredArgsConstructor;
89
import org.springframework.jdbc.core.JdbcTemplate;
910
import org.springframework.jdbc.core.RowMapper;
@@ -85,7 +86,53 @@ select count(*)
8586
from reservation
8687
where theme_id = ?
8788
""";
88-
;
89+
private static final String EXISTS_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL =
90+
"""
91+
select exists(
92+
select 1
93+
from reservation r
94+
where time_id = ? and date_id = ? and theme_id = ?
95+
)
96+
""";
97+
private static final String EXISTS_OTHER_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL =
98+
"""
99+
select exists(
100+
select 1
101+
from reservation r
102+
where r.id <> ? and time_id = ? and date_id = ? and theme_id = ?
103+
)
104+
""";
105+
private static final String FIND_BY_NAME_SQL =
106+
"""
107+
select r.id, r.name,
108+
rd.id as date_id, rd.date,
109+
rt.id as time_id, rt.start_at,
110+
th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url
111+
from reservation r
112+
join reservation_date rd on r.date_id = rd.id
113+
join reservation_time rt on r.time_id = rt.id
114+
join theme th on r.theme_id = th.id
115+
where r.name = ?
116+
order by rd.date desc, rt.start_at desc, r.id desc
117+
""";
118+
private static final String FIND_BY_ID_SQL =
119+
"""
120+
select r.id, r.name,
121+
rd.id as date_id, rd.date,
122+
rt.id as time_id, rt.start_at,
123+
th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url
124+
from reservation r
125+
join reservation_date rd on r.date_id = rd.id
126+
join reservation_time rt on r.time_id = rt.id
127+
join theme th on r.theme_id = th.id
128+
where r.id = ?
129+
""";
130+
private static final String UPDATE_SQL =
131+
"""
132+
update reservation
133+
set name = ?, date_id = ?, time_id = ?, theme_id = ?
134+
where id = ?
135+
""";
89136

90137
private final JdbcTemplate jdbcTemplate;
91138

@@ -151,6 +198,58 @@ public int countByThemeId(Long themeId) {
151198
return count;
152199
}
153200

201+
@Override
202+
public boolean existsReservation(Long timeId, Long dateId, Long themeId) {
203+
Boolean exists = jdbcTemplate.queryForObject(
204+
EXISTS_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL,
205+
Boolean.class,
206+
timeId,
207+
dateId,
208+
themeId
209+
);
210+
return exists != null && exists;
211+
}
212+
213+
@Override
214+
public boolean existsOtherReservation(Long id, Long timeId, Long dateId, Long themeId) {
215+
Boolean exists = jdbcTemplate.queryForObject(
216+
EXISTS_OTHER_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL,
217+
Boolean.class,
218+
id,
219+
timeId,
220+
dateId,
221+
themeId
222+
);
223+
return exists != null && exists;
224+
}
225+
226+
@Override
227+
public List<Reservation> findByName(String name) {
228+
return jdbcTemplate.query(FIND_BY_NAME_SQL, reservationRowMapper(), name);
229+
}
230+
231+
@Override
232+
public Optional<Reservation> findById(Long id) {
233+
List<Reservation> result = jdbcTemplate.query(FIND_BY_ID_SQL, reservationRowMapper(), id);
234+
return result.stream().findFirst();
235+
}
236+
237+
@Override
238+
public Optional<Reservation> update(Long id, Reservation withoutId) {
239+
int updatedCount = jdbcTemplate.update(
240+
UPDATE_SQL,
241+
withoutId.getName(),
242+
withoutId.getDate().getId(),
243+
withoutId.getTime().getId(),
244+
withoutId.getTheme().getId(),
245+
id
246+
);
247+
if (updatedCount == 0) {
248+
return Optional.empty();
249+
}
250+
return findById(id);
251+
}
252+
154253
private RowMapper<Reservation> reservationRowMapper() {
155254
return (rs, rowNum) -> Reservation.of(
156255
rs.getLong(COLUMN_ID),

src/main/java/roomescape/domain/reservation/Reservation.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import roomescape.domain.reservationtime.ReservationTime;
66
import roomescape.domain.theme.Theme;
77
import roomescape.support.exception.BadRequestException;
8-
import roomescape.support.exception.ReservationErrorCode;
9-
import roomescape.support.exception.ReservationTimeErrorCode;
10-
import roomescape.support.exception.ThemeErrorCode;
8+
import roomescape.support.exception.errors.ReservationErrors;
9+
import roomescape.support.exception.errors.ReservationTimeErrors;
10+
import roomescape.support.exception.errors.ThemeErrors;
1111

1212
@Getter
1313
public class Reservation {
1414

15+
private static final int MAX_NAME_LENGTH = 10;
16+
1517
private final Long id;
1618
private final String name;
1719
private final ReservationDate date;
@@ -76,16 +78,19 @@ public static Reservation of(
7678

7779
private static void validate(String name, ReservationDate date, ReservationTime time, Theme theme) {
7880
if (name == null || name.isBlank()) {
79-
throw new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_NAME);
81+
throw new BadRequestException(ReservationErrors.INVALID_RESERVATION_NAME);
82+
}
83+
if (name.length() > MAX_NAME_LENGTH) {
84+
throw new BadRequestException(ReservationErrors.INVALID_RESERVATION_NAME_LENGTH);
8085
}
8186
if (date == null) {
82-
throw new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_DATE);
87+
throw new BadRequestException(ReservationErrors.INVALID_RESERVATION_DATE);
8388
}
8489
if (time == null) {
85-
throw new BadRequestException(ReservationTimeErrorCode.INVALID_RESERVATION_TIME);
90+
throw new BadRequestException(ReservationTimeErrors.INVALID_RESERVATION_TIME);
8691
}
8792
if (theme == null) {
88-
throw new BadRequestException(ThemeErrorCode.INVALID_THEME);
93+
throw new BadRequestException(ThemeErrors.INVALID_THEME);
8994
}
9095
}
9196
}
Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,63 @@
11
package roomescape.domain.reservation;
22

3-
import jakarta.servlet.http.HttpServletRequest;
4-
import java.util.List;
3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.NotBlank;
55
import lombok.RequiredArgsConstructor;
66
import org.springframework.http.HttpStatus;
77
import org.springframework.http.ResponseEntity;
8+
import org.springframework.validation.annotation.Validated;
89
import org.springframework.web.bind.annotation.DeleteMapping;
910
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PatchMapping;
1012
import org.springframework.web.bind.annotation.PathVariable;
1113
import org.springframework.web.bind.annotation.PostMapping;
1214
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RequestParam;
1316
import org.springframework.web.bind.annotation.RestController;
14-
import roomescape.support.auth.AdminRequestValidator;
1517
import roomescape.domain.reservation.dto.CreateReservationRequest;
1618
import roomescape.domain.reservation.dto.CreateReservationResponse;
17-
import roomescape.domain.reservation.dto.ReservationResponse;
19+
import roomescape.domain.reservation.dto.UpdateReservationRequest;
20+
import roomescape.domain.reservation.dto.UserReservationResponse;
1821

22+
@Validated
1923
@RestController
2024
@RequiredArgsConstructor
2125
public class ReservationController {
2226

2327
private final ReservationService reservationService;
24-
private final AdminRequestValidator validator;
25-
26-
@GetMapping("/admin/reservations")
27-
public ResponseEntity<List<ReservationResponse>> getAllReservation(HttpServletRequest request) {
28-
if (validator.isUnauthorized(request)) {
29-
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
30-
}
31-
List<ReservationResponse> response = reservationService.getAllReservations();
32-
return ResponseEntity.ok(response);
33-
}
3428

3529
@PostMapping("/reservations")
36-
public ResponseEntity<CreateReservationResponse> createReservation(@RequestBody CreateReservationRequest request) {
37-
request.validate();
30+
public ResponseEntity<CreateReservationResponse> createReservation(
31+
@Valid @RequestBody CreateReservationRequest request
32+
) {
3833
CreateReservationResponse response = reservationService.createReservation(request);
3934
return ResponseEntity.status(HttpStatus.CREATED).body(response);
4035
}
4136

42-
@DeleteMapping("/admin/reservations/{id}")
43-
public ResponseEntity<Void> deleteReservation(HttpServletRequest request, @PathVariable Long id) {
44-
if (validator.isUnauthorized(request)) {
45-
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
46-
}
47-
reservationService.deleteReservation(id);
37+
@GetMapping("/reservations")
38+
public ResponseEntity<UserReservationResponse> getUserReservations(
39+
@RequestParam
40+
@NotBlank(message = "예약자 이름은 필수 입력값 입니다.")
41+
String name
42+
) {
43+
UserReservationResponse response = reservationService.getUserReservations(name);
44+
return ResponseEntity.ok(response);
45+
}
46+
47+
@DeleteMapping("/reservations/{id}")
48+
public ResponseEntity<Void> cancelUserReservation(
49+
@PathVariable Long id
50+
) {
51+
reservationService.cancelUserReservation(id);
52+
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
53+
}
54+
55+
@PatchMapping("/reservations/{id}")
56+
public ResponseEntity<Void> updateReservation(
57+
@PathVariable Long id,
58+
@RequestBody UpdateReservationRequest request
59+
) {
60+
reservationService.updateReservation(id, request);
4861
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
4962
}
5063
}

src/main/java/roomescape/domain/reservation/ReservationRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.time.LocalDate;
44
import java.util.List;
5+
import java.util.Optional;
56
import roomescape.domain.theme.Theme;
67

78
public interface ReservationRepository {
@@ -21,4 +22,14 @@ public interface ReservationRepository {
2122
List<Theme> findPopularThemes(int rankLimit, LocalDate startDay, LocalDate today);
2223

2324
int countByThemeId(Long id);
25+
26+
boolean existsReservation(Long timeId, Long dateId, Long themeId);
27+
28+
boolean existsOtherReservation(Long id, Long timeId, Long dateId, Long themeId);
29+
30+
List<Reservation> findByName(String name);
31+
32+
Optional<Reservation> findById(Long id);
33+
34+
Optional<Reservation> update(Long id, Reservation withoutId);
2435
}

0 commit comments

Comments
 (0)