diff --git a/README.md b/README.md index 534381a284..db059bea78 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,13 @@ - 사용자는 이름, 날짜 ID, 시간 ID, 테마 ID를 입력해 예약을 생성한다. - 예약 생성 시 이름이 비어 있으면 예외를 반환한다. - 예약 생성 시 날짜, 시간, 테마 값이 누락되면 예외를 반환한다. +- 사용자는 이름으로 본인의 예약 목록을 조회한다. +- 사용자는 본인의 예약을 취소한다. +- 사용자는 본인의 예약 날짜와 시간을 변경한다. +- 예약 변경 시 날짜와 시간이 모두 누락되면 예외를 반환한다. +- 예약 변경 시 존재하지 않는 날짜나 시간이 입력되면 예외를 반환한다. +- 예약 변경 시 변경하려는 날짜와 시간이 현재보다 이전이면 예외를 반환한다. +- 예약 변경 시 같은 날짜, 시간, 테마의 다른 예약이 이미 존재하면 예외를 반환한다. - 관리자는 전체 예약 목록을 조회한다. - 관리자는 예약 ID로 예약을 삭제한다. @@ -101,7 +108,7 @@ ```json { - "name": "쿠키", + "name": "보예", "dateId": 1, "timeId": 2, "themeId": 3 @@ -113,7 +120,7 @@ ```json { "id": 29, - "name": "쿠키", + "name": "보예", "date": "2026-05-01", "time": "11:00", "theme": { @@ -124,6 +131,55 @@ } ``` +#### `GET /reservations?name={name}` + +- 설명: 사용자 이름으로 예약 목록 조회 +- 응답 `200 OK` + +```json +{ + "name": "보예", + "reservation": [ + { + "reservationId": 29, + "date": { + "id": 1, + "startWhen": "2026-05-01" + }, + "time": { + "id": 2, + "startAt": "11:00" + }, + "theme": { + "id": 3, + "name": "청춘물", + "content": "학교 배경인 테마 입니다.", + "url": "/themes/youth" + } + } + ] +} +``` + +#### `PATCH /reservations/{id}` + +- 설명: 사용자 예약 날짜와 시간 변경 +- 요청 본문 + +```json +{ + "startWhen": "2026-05-10", + "startAt": "15:00" +} +``` + +- 응답 `204 No Content` + +#### `DELETE /reservations/{id}` + +- 설명: 사용자 예약 취소 +- 응답 `204 No Content` + #### `DELETE /admin/reservations/{id}` - 설명: 예약 삭제 @@ -190,7 +246,7 @@ ### 예약 시간 -#### `GET /times?themeId={themeId}&dateId={dateId}` +#### `GET /reservation-times/availability?themeId={themeId}&dateId={dateId}` - 설명: 특정 테마와 날짜의 예약 가능 시간 조회 - 응답 `200 OK` diff --git a/build.gradle b/build.gradle index 8c8d6b967c..b041a401f7 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/roomescape/config/TimeConfig.java b/src/main/java/roomescape/config/TimeConfig.java new file mode 100644 index 0000000000..18823bee03 --- /dev/null +++ b/src/main/java/roomescape/config/TimeConfig.java @@ -0,0 +1,14 @@ +package roomescape.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java b/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java index 31e0c746b5..fda6f94557 100644 --- a/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java +++ b/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java @@ -4,6 +4,7 @@ import java.sql.Statement; import java.time.LocalDate; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -85,7 +86,53 @@ select count(*) from reservation where theme_id = ? """; - ; + private static final String EXISTS_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL = + """ + select exists( + select 1 + from reservation r + where time_id = ? and date_id = ? and theme_id = ? + ) + """; + private static final String EXISTS_OTHER_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL = + """ + select exists( + select 1 + from reservation r + where r.id <> ? and time_id = ? and date_id = ? and theme_id = ? + ) + """; + private static final String FIND_BY_NAME_SQL = + """ + select r.id, r.name, + rd.id as date_id, rd.date, + rt.id as time_id, rt.start_at, + th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url + from reservation r + join reservation_date rd on r.date_id = rd.id + join reservation_time rt on r.time_id = rt.id + join theme th on r.theme_id = th.id + where r.name = ? + order by rd.date desc, rt.start_at desc, r.id desc + """; + private static final String FIND_BY_ID_SQL = + """ + select r.id, r.name, + rd.id as date_id, rd.date, + rt.id as time_id, rt.start_at, + th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url + from reservation r + join reservation_date rd on r.date_id = rd.id + join reservation_time rt on r.time_id = rt.id + join theme th on r.theme_id = th.id + where r.id = ? + """; + private static final String UPDATE_SQL = + """ + update reservation + set name = ?, date_id = ?, time_id = ?, theme_id = ? + where id = ? + """; private final JdbcTemplate jdbcTemplate; @@ -151,6 +198,58 @@ public int countByThemeId(Long themeId) { return count; } + @Override + public boolean existsReservation(Long timeId, Long dateId, Long themeId) { + Boolean exists = jdbcTemplate.queryForObject( + EXISTS_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL, + Boolean.class, + timeId, + dateId, + themeId + ); + return exists != null && exists; + } + + @Override + public boolean existsOtherReservation(Long id, Long timeId, Long dateId, Long themeId) { + Boolean exists = jdbcTemplate.queryForObject( + EXISTS_OTHER_RESERVATION_BY_TIME_AND_DATE_AND_THEME_SQL, + Boolean.class, + id, + timeId, + dateId, + themeId + ); + return exists != null && exists; + } + + @Override + public List findByName(String name) { + return jdbcTemplate.query(FIND_BY_NAME_SQL, reservationRowMapper(), name); + } + + @Override + public Optional findById(Long id) { + List result = jdbcTemplate.query(FIND_BY_ID_SQL, reservationRowMapper(), id); + return result.stream().findFirst(); + } + + @Override + public Optional update(Long id, Reservation withoutId) { + int updatedCount = jdbcTemplate.update( + UPDATE_SQL, + withoutId.getName(), + withoutId.getDate().getId(), + withoutId.getTime().getId(), + withoutId.getTheme().getId(), + id + ); + if (updatedCount == 0) { + return Optional.empty(); + } + return findById(id); + } + private RowMapper reservationRowMapper() { return (rs, rowNum) -> Reservation.of( rs.getLong(COLUMN_ID), diff --git a/src/main/java/roomescape/domain/reservation/Reservation.java b/src/main/java/roomescape/domain/reservation/Reservation.java index d5ddaf6e34..5325224053 100644 --- a/src/main/java/roomescape/domain/reservation/Reservation.java +++ b/src/main/java/roomescape/domain/reservation/Reservation.java @@ -5,13 +5,15 @@ import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; import roomescape.support.exception.BadRequestException; -import roomescape.support.exception.ReservationErrorCode; -import roomescape.support.exception.ReservationTimeErrorCode; -import roomescape.support.exception.ThemeErrorCode; +import roomescape.support.exception.errors.ReservationErrors; +import roomescape.support.exception.errors.ReservationTimeErrors; +import roomescape.support.exception.errors.ThemeErrors; @Getter public class Reservation { + private static final int MAX_NAME_LENGTH = 10; + private final Long id; private final String name; private final ReservationDate date; @@ -76,16 +78,19 @@ public static Reservation of( private static void validate(String name, ReservationDate date, ReservationTime time, Theme theme) { if (name == null || name.isBlank()) { - throw new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_NAME); + throw new BadRequestException(ReservationErrors.INVALID_RESERVATION_NAME); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new BadRequestException(ReservationErrors.INVALID_RESERVATION_NAME_LENGTH); } if (date == null) { - throw new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_DATE); + throw new BadRequestException(ReservationErrors.INVALID_RESERVATION_DATE); } if (time == null) { - throw new BadRequestException(ReservationTimeErrorCode.INVALID_RESERVATION_TIME); + throw new BadRequestException(ReservationTimeErrors.INVALID_RESERVATION_TIME); } if (theme == null) { - throw new BadRequestException(ThemeErrorCode.INVALID_THEME); + throw new BadRequestException(ThemeErrors.INVALID_THEME); } } } diff --git a/src/main/java/roomescape/domain/reservation/ReservationController.java b/src/main/java/roomescape/domain/reservation/ReservationController.java index 54e3210894..bd805a2a15 100644 --- a/src/main/java/roomescape/domain/reservation/ReservationController.java +++ b/src/main/java/roomescape/domain/reservation/ReservationController.java @@ -1,50 +1,63 @@ package roomescape.domain.reservation; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; -import roomescape.support.auth.AdminRequestValidator; import roomescape.domain.reservation.dto.CreateReservationRequest; import roomescape.domain.reservation.dto.CreateReservationResponse; -import roomescape.domain.reservation.dto.ReservationResponse; +import roomescape.domain.reservation.dto.UpdateReservationRequest; +import roomescape.domain.reservation.dto.UserReservationResponse; +@Validated @RestController @RequiredArgsConstructor public class ReservationController { private final ReservationService reservationService; - private final AdminRequestValidator validator; - - @GetMapping("/admin/reservations") - public ResponseEntity> getAllReservation(HttpServletRequest request) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - List response = reservationService.getAllReservations(); - return ResponseEntity.ok(response); - } @PostMapping("/reservations") - public ResponseEntity createReservation(@RequestBody CreateReservationRequest request) { - request.validate(); + public ResponseEntity createReservation( + @Valid @RequestBody CreateReservationRequest request + ) { CreateReservationResponse response = reservationService.createReservation(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } - @DeleteMapping("/admin/reservations/{id}") - public ResponseEntity deleteReservation(HttpServletRequest request, @PathVariable Long id) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - reservationService.deleteReservation(id); + @GetMapping("/reservations") + public ResponseEntity getUserReservations( + @RequestParam + @NotBlank(message = "예약자 이름은 필수 입력값 입니다.") + String name + ) { + UserReservationResponse response = reservationService.getUserReservations(name); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/reservations/{id}") + public ResponseEntity cancelUserReservation( + @PathVariable Long id + ) { + reservationService.cancelUserReservation(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PatchMapping("/reservations/{id}") + public ResponseEntity updateReservation( + @PathVariable Long id, + @RequestBody UpdateReservationRequest request + ) { + reservationService.updateReservation(id, request); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } } diff --git a/src/main/java/roomescape/domain/reservation/ReservationRepository.java b/src/main/java/roomescape/domain/reservation/ReservationRepository.java index 0e64f42a51..42047b9ee2 100644 --- a/src/main/java/roomescape/domain/reservation/ReservationRepository.java +++ b/src/main/java/roomescape/domain/reservation/ReservationRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import roomescape.domain.theme.Theme; public interface ReservationRepository { @@ -21,4 +22,14 @@ public interface ReservationRepository { List findPopularThemes(int rankLimit, LocalDate startDay, LocalDate today); int countByThemeId(Long id); + + boolean existsReservation(Long timeId, Long dateId, Long themeId); + + boolean existsOtherReservation(Long id, Long timeId, Long dateId, Long themeId); + + List findByName(String name); + + Optional findById(Long id); + + Optional update(Long id, Reservation withoutId); } diff --git a/src/main/java/roomescape/domain/reservation/ReservationService.java b/src/main/java/roomescape/domain/reservation/ReservationService.java index c4018d37ed..2447dc2c85 100644 --- a/src/main/java/roomescape/domain/reservation/ReservationService.java +++ b/src/main/java/roomescape/domain/reservation/ReservationService.java @@ -1,24 +1,30 @@ package roomescape.domain.reservation; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import roomescape.domain.reservation.admin.dto.ReservationResponse; import roomescape.domain.reservation.dto.CreateReservationRequest; import roomescape.domain.reservation.dto.CreateReservationResponse; -import roomescape.domain.reservation.dto.ReservationResponse; +import roomescape.domain.reservation.dto.UpdateReservationRequest; +import roomescape.domain.reservation.dto.UserReservationResponse; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationdate.ReservationDateRepository; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.reservationtime.ReservationTimeRepository; import roomescape.domain.theme.Theme; import roomescape.domain.theme.ThemeRepository; +import roomescape.support.exception.BadRequestException; import roomescape.support.exception.NotFoundException; -import roomescape.support.exception.ReservationDateErrorCode; -import roomescape.support.exception.ReservationTimeErrorCode; -import roomescape.support.exception.ThemeErrorCode; +import roomescape.support.exception.errors.ReservationDateErrors; +import roomescape.support.exception.errors.ReservationErrors; +import roomescape.support.exception.errors.ReservationTimeErrors; +import roomescape.support.exception.errors.ThemeErrors; -@Slf4j @Service @RequiredArgsConstructor public class ReservationService { @@ -27,15 +33,19 @@ public class ReservationService { private final ReservationTimeRepository reservationTimeRepository; private final ReservationDateRepository reservationDateRepository; private final ThemeRepository themeRepository; + private final Clock clock; public CreateReservationResponse createReservation(CreateReservationRequest request) { ReservationTime reservationTime = reservationTimeRepository.findById(request.timeId()) - .orElseThrow(() -> new NotFoundException(ReservationTimeErrorCode.RESERVATION_TIME_NOT_EXIST)); + .orElseThrow(() -> new NotFoundException(ReservationTimeErrors.RESERVATION_TIME_NOT_EXIST)); ReservationDate reservationDate = reservationDateRepository.findById(request.dateId()) - .orElseThrow(() -> new NotFoundException(ReservationDateErrorCode.RESERVATION_DATE_NOT_EXIST)); + .orElseThrow(() -> new NotFoundException(ReservationDateErrors.RESERVATION_DATE_NOT_EXIST)); + validateReservationScheduleToCreate(reservationDate, reservationTime); Theme theme = themeRepository.findById(request.themeId()) - .orElseThrow(() -> new NotFoundException(ThemeErrorCode.THEME_NOT_EXIST)); - Reservation savedReservation = reservationRepository.save(request.toEntity(reservationDate, reservationTime, theme)); + .orElseThrow(() -> new NotFoundException(ThemeErrors.THEME_NOT_EXIST)); + validateDuplicated(reservationTime, reservationDate, theme); + Reservation savedReservation = reservationRepository.save( + request.toEntity(reservationDate, reservationTime, theme)); return CreateReservationResponse.from(savedReservation); } @@ -45,10 +55,120 @@ public List getAllReservations() { .toList(); } - public void deleteReservation(Long id) { - int deletedCount = reservationRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("이미 삭제된 예약 삭제 요청이 들어왔습니다. reservationId={}", id); + public UserReservationResponse getUserReservations(String name) { + List reservations = reservationRepository.findByName(name); + return UserReservationResponse.of(name, reservations); + } + + public void cancelReservationByAdmin(Long id) { + reservationRepository.deleteById(id); + } + + public void cancelUserReservation(Long id) { + Reservation reservation = reservationRepository.findById(id) + .orElseThrow(() -> new NotFoundException(ReservationErrors.RESERVATION_NOT_FOUND)); + validateUserCanDeleteReservation(reservation); + reservationRepository.deleteById(id); + } + + public void updateReservation(Long id, UpdateReservationRequest request) { + Reservation reservation = reservationRepository.findById(id) + .orElseThrow(() -> new NotFoundException(ReservationErrors.RESERVATION_NOT_FOUND)); + ReservationTime reservationTime = reservation.getTime(); + ReservationDate reservationDate = reservation.getDate(); + reservationTime = getReservationTime(request, reservationTime); + reservationDate = getReservationDate(request, reservationDate); + validateReservationScheduleToCreate(reservationDate, reservationTime); + validateDuplicatedWithOther(id, reservationTime, reservationDate, reservation.getTheme()); + Reservation updatedReservation = Reservation.createWithoutId( + reservation.getName(), + reservationDate, + reservationTime, + reservation.getTheme() + ); + reservationRepository.update(id, updatedReservation) + .orElseThrow(() -> new NotFoundException(ReservationErrors.RESERVATION_NOT_FOUND)); + } + + private void validateReservationScheduleToCreate( + ReservationDate reservationDate, + ReservationTime reservationTime + ) { + LocalDateTime now = LocalDateTime.now(clock); + LocalDate today = now.toLocalDate(); + LocalTime currentTime = now.toLocalTime(); + if (isPastDate(reservationDate, today)) { + throw new BadRequestException(ReservationDateErrors.RESERVATION_DATE_MUST_BE_TODAY_OR_LATER, today); + } + if (isPastTimeToday(reservationDate, reservationTime, today, currentTime)) { + throw new BadRequestException(ReservationTimeErrors.RESERVATION_TIME_SHOULD_BE_NOW_OR_LATER, currentTime); + } + } + + private void validateDuplicated(ReservationTime reservationTime, ReservationDate reservationDate, Theme theme) { + if (isExistReservation(reservationTime, reservationDate, theme)) { + throw new BadRequestException(ReservationErrors.DUPLICATED_RESERVATION); + } + } + + private boolean isExistReservation(ReservationTime reservationTime, ReservationDate reservationDate, Theme theme) { + return reservationRepository.existsReservation(reservationTime.getId(), reservationDate.getId(), theme.getId()); + } + + private void validateDuplicatedWithOther( + Long id, + ReservationTime reservationTime, + ReservationDate reservationDate, + Theme theme + ) { + if (reservationRepository.existsOtherReservation( + id, + reservationTime.getId(), + reservationDate.getId(), + theme.getId() + )) { + throw new BadRequestException(ReservationErrors.DUPLICATED_RESERVATION); + } + } + + private void validateUserCanDeleteReservation(Reservation reservation) { + LocalDateTime now = LocalDateTime.now(clock); + LocalDate today = now.toLocalDate(); + LocalTime currentTime = now.toLocalTime(); + if (isPastDate(reservation.getDate(), today)) { + throw new BadRequestException(ReservationDateErrors.PAST_RESERVATION_DATE_CANNOT_BE_DELETED, today); + } + if (isPastTimeToday(reservation.getDate(), reservation.getTime(), today, currentTime)) { + throw new BadRequestException(ReservationTimeErrors.PAST_RESERVATION_TiME_CANNOT_BE_DELETED, currentTime); + } + } + + private boolean isPastDate(ReservationDate reservationDate, LocalDate today) { + return reservationDate.isBefore(today); + } + + private boolean isPastTimeToday( + ReservationDate reservationDate, + ReservationTime reservationTime, + LocalDate today, + LocalTime now + ) { + return reservationDate.isSame(today) && reservationTime.isBefore(now); + } + + private ReservationDate getReservationDate(UpdateReservationRequest request, ReservationDate reservationDate) { + if (request.startWhen() != null) { + reservationDate = reservationDateRepository.findByDate(request.startWhen()) + .orElseThrow(() -> new NotFoundException(ReservationDateErrors.RESERVATION_DATE_NOT_EXIST)); + } + return reservationDate; + } + + private ReservationTime getReservationTime(UpdateReservationRequest request, ReservationTime reservationTime) { + if (request.startAt() != null) { + reservationTime = reservationTimeRepository.findByStartAt(request.startAt()) + .orElseThrow(() -> new NotFoundException(ReservationTimeErrors.RESERVATION_TIME_NOT_EXIST)); } + return reservationTime; } } diff --git a/src/main/java/roomescape/domain/reservation/admin/AdminReservationController.java b/src/main/java/roomescape/domain/reservation/admin/AdminReservationController.java new file mode 100644 index 0000000000..756573515f --- /dev/null +++ b/src/main/java/roomescape/domain/reservation/admin/AdminReservationController.java @@ -0,0 +1,40 @@ +package roomescape.domain.reservation.admin; + +import jakarta.servlet.http.HttpServletRequest; +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.RestController; +import roomescape.domain.reservation.ReservationService; +import roomescape.domain.reservation.admin.dto.ReservationResponse; +import roomescape.support.auth.AdminRequestValidator; + +@RestController +@RequiredArgsConstructor +public class AdminReservationController { + + private final ReservationService reservationService; + private final AdminRequestValidator validator; + + @GetMapping("/admin/reservations") + public ResponseEntity> getAllReservation(HttpServletRequest request) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + List response = reservationService.getAllReservations(); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/admin/reservations/{id}") + public ResponseEntity cancelReservation(HttpServletRequest request, @PathVariable Long id) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + reservationService.cancelReservationByAdmin(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/roomescape/domain/reservation/dto/ReservationResponse.java b/src/main/java/roomescape/domain/reservation/admin/dto/ReservationResponse.java similarity index 96% rename from src/main/java/roomescape/domain/reservation/dto/ReservationResponse.java rename to src/main/java/roomescape/domain/reservation/admin/dto/ReservationResponse.java index 02884b3128..1b7d0ec83d 100644 --- a/src/main/java/roomescape/domain/reservation/dto/ReservationResponse.java +++ b/src/main/java/roomescape/domain/reservation/admin/dto/ReservationResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.reservation.dto; +package roomescape.domain.reservation.admin.dto; import com.fasterxml.jackson.annotation.JsonFormat; import java.time.LocalDate; diff --git a/src/main/java/roomescape/domain/reservation/dto/CreateReservationRequest.java b/src/main/java/roomescape/domain/reservation/dto/CreateReservationRequest.java index 0306497e49..897c4b402a 100644 --- a/src/main/java/roomescape/domain/reservation/dto/CreateReservationRequest.java +++ b/src/main/java/roomescape/domain/reservation/dto/CreateReservationRequest.java @@ -1,36 +1,26 @@ package roomescape.domain.reservation.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import roomescape.domain.reservation.Reservation; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; -import roomescape.support.exception.BadRequestException; -import roomescape.support.exception.ReservationErrorCode; -import roomescape.support.exception.ReservationTimeErrorCode; -import roomescape.support.exception.ThemeErrorCode; public record CreateReservationRequest( + @NotBlank(message = "이름은 비어있을 수 없습니다.") String name, + + @NotNull(message = "날짜는 필수 선택 사항 입니다. 날짜를 선택해주세요.") Long dateId, + + @NotNull(message = "시간은 필수 선택 사항 입니다. 시간을 선택해주세요.") Long timeId, + + @NotNull(message = "테마는 필수 선택 사항 입니다. 테마를 선택해주세요.") Long themeId ) { - public void validate() { - if (name == null || name.isBlank()) { - throw new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_NAME); - } - if (dateId == null) { - throw new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_DATE); - } - if (timeId == null) { - throw new BadRequestException(ReservationTimeErrorCode.INVALID_RESERVATION_TIME); - } - if (themeId == null) { - throw new BadRequestException(ThemeErrorCode.INVALID_THEME); - } - } - public Reservation toEntity(ReservationDate reservationDate, ReservationTime reservationTime, Theme theme) { return Reservation.createWithoutId( name, diff --git a/src/main/java/roomescape/domain/reservation/dto/UpdateReservationRequest.java b/src/main/java/roomescape/domain/reservation/dto/UpdateReservationRequest.java new file mode 100644 index 0000000000..76aaba3d29 --- /dev/null +++ b/src/main/java/roomescape/domain/reservation/dto/UpdateReservationRequest.java @@ -0,0 +1,11 @@ +package roomescape.domain.reservation.dto; + +import java.time.LocalDate; +import java.time.LocalTime; + +public record UpdateReservationRequest( + LocalDate startWhen, + LocalTime startAt +) { + +} diff --git a/src/main/java/roomescape/domain/reservation/dto/UserReservationResponse.java b/src/main/java/roomescape/domain/reservation/dto/UserReservationResponse.java new file mode 100644 index 0000000000..8aeb9630bc --- /dev/null +++ b/src/main/java/roomescape/domain/reservation/dto/UserReservationResponse.java @@ -0,0 +1,80 @@ +package roomescape.domain.reservation.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationdate.ReservationDate; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; + +public record UserReservationResponse( + String name, + List reservation +) { + + public static UserReservationResponse of(String name, List reservation) { + return new UserReservationResponse( + name, + reservation.stream() + .map(ReservationPayload::from) + .toList() + ); + } + + public record ReservationPayload( + Long reservationId, + ReservationDatePayload date, + ReservationTimePayload time, + ThemePayload theme + ) { + + public static ReservationPayload from(Reservation reservation) { + return new ReservationPayload( + reservation.getId(), + ReservationDatePayload.from(reservation.getDate()), + ReservationTimePayload.from(reservation.getTime()), + ThemePayload.from(reservation.getTheme()) + ); + } + } + + public record ReservationDatePayload( + Long id, + LocalDate startWhen + ) { + + public static ReservationDatePayload from(ReservationDate date) { + return new ReservationDatePayload(date.getId(), date.getDate()); + } + } + + public record ReservationTimePayload( + Long id, + @JsonFormat(pattern = "HH:mm") + LocalTime startAt + ) { + + public static ReservationTimePayload from(ReservationTime reservationTime) { + return new ReservationTimePayload(reservationTime.getId(), reservationTime.getStartAt()); + } + } + + public record ThemePayload( + Long id, + String name, + String content, + String url + ) { + + public static ThemePayload from(Theme theme) { + return new ThemePayload( + theme.getId(), + theme.getName(), + theme.getContent(), + theme.getUrl() + ); + } + } +} diff --git a/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java b/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java index 188cbc62f2..a5b1927132 100644 --- a/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java +++ b/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java @@ -3,6 +3,7 @@ import java.sql.Date; import java.sql.PreparedStatement; import java.sql.Statement; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -12,7 +13,7 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import roomescape.support.exception.InternalServerException; -import roomescape.support.exception.RoomescapeErrorCode; +import roomescape.support.exception.errors.RoomescapeErrors; @Repository @RequiredArgsConstructor @@ -23,6 +24,7 @@ public class JdbcReservationDateRepository implements ReservationDateRepository private static final String INSERT_SQL = "insert into reservation_date(`date`) values (?)"; private static final String FIND_BY_ID_SQL = "select id, `date` from reservation_date where id = ?"; + private static final String FIND_BY_DATE_SQL = "select id, `date` from reservation_date where `date` = ?"; private static final String FIND_ALL_SQL = "select id, `date` from reservation_date order by id"; private static final String DELETE_BY_ID_SQL = "delete from reservation_date where id = ?"; @@ -59,6 +61,12 @@ public int deleteById(Long id) { return jdbcTemplate.update(DELETE_BY_ID_SQL, id); } + @Override + public Optional findByDate(LocalDate startWhen) { + List result = jdbcTemplate.query(FIND_BY_DATE_SQL, reservationDateRowMapper(), startWhen); + return result.stream().findFirst(); + } + private RowMapper reservationDateRowMapper() { return (rs, rowNum) -> ReservationDate.of( rs.getLong(COLUMN_ID), @@ -68,7 +76,7 @@ private RowMapper reservationDateRowMapper() { private long extractId(KeyHolder keyHolder) { if (keyHolder.getKey() == null) { - throw new InternalServerException(RoomescapeErrorCode.INVALID_GENERATED_KEY); + throw new InternalServerException(RoomescapeErrors.INVALID_GENERATED_KEY); } return keyHolder.getKey().longValue(); } diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDate.java b/src/main/java/roomescape/domain/reservationdate/ReservationDate.java index d761a88932..4e8477b4a3 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDate.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDate.java @@ -24,4 +24,12 @@ public static ReservationDate createWithoutId(LocalDate reservationDate) { reservationDate ); } + + public boolean isBefore(LocalDate compareDate) { + return date.isBefore(compareDate); + } + + public boolean isSame(LocalDate compareDate) { + return date.isEqual(compareDate); + } } diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDateController.java b/src/main/java/roomescape/domain/reservationdate/ReservationDateController.java index d4272726bf..d4795f1cda 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDateController.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDateController.java @@ -1,20 +1,10 @@ package roomescape.domain.reservationdate; -import jakarta.servlet.http.HttpServletRequest; 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.RestController; -import roomescape.support.auth.AdminRequestValidator; -import roomescape.domain.reservationdate.dto.AdminReservationDateResponse; -import roomescape.domain.reservationdate.dto.CreateReservationDateRequest; -import roomescape.domain.reservationdate.dto.CreateReservationDateResponse; import roomescape.domain.reservationdate.dto.ReservationDateResponse; @RestController @@ -22,40 +12,6 @@ public class ReservationDateController { private final ReservationDateService reservationDateService; - private final AdminRequestValidator validator; - - @GetMapping("/admin/reservation-dates") - public ResponseEntity> getAllReservationDateForAdmin( - HttpServletRequest request - ) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - List response = reservationDateService.getAllReservationDateForAdmin(); - return ResponseEntity.ok(response); - } - - @PostMapping("/admin/reservation-dates") - public ResponseEntity createReservationDate( - HttpServletRequest httpServletRequest, - @RequestBody CreateReservationDateRequest createReservationDateRequest - ) { - if (validator.isUnauthorized(httpServletRequest)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - CreateReservationDateResponse response = reservationDateService - .createReservationDate(createReservationDateRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @DeleteMapping("/admin/reservation-dates/{id}") - public ResponseEntity deleteReservationDate(@PathVariable Long id, HttpServletRequest request) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - reservationDateService.deleteReservationDate(id); - return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); - } @GetMapping("/reservation-dates") public ResponseEntity> getAllReservationDates() { diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java b/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java index 487ce2cebf..7f245d2125 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java @@ -1,5 +1,6 @@ package roomescape.domain.reservationdate; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -12,4 +13,6 @@ public interface ReservationDateRepository { ReservationDate save(ReservationDate reservationDate); int deleteById(Long id); + + Optional findByDate(LocalDate startWhen); } diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java b/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java index 3df6e7590f..491b00ba4a 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java @@ -2,17 +2,15 @@ import java.util.List; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import roomescape.domain.reservation.ReservationRepository; -import roomescape.domain.reservationdate.dto.AdminReservationDateResponse; -import roomescape.domain.reservationdate.dto.CreateReservationDateRequest; -import roomescape.domain.reservationdate.dto.CreateReservationDateResponse; +import roomescape.domain.reservationdate.admin.dto.AdminReservationDateResponse; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateRequest; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateResponse; import roomescape.domain.reservationdate.dto.ReservationDateResponse; import roomescape.support.exception.ConflictException; -import roomescape.support.exception.ReservationDateErrorCode; +import roomescape.support.exception.errors.ReservationDateErrors; -@Slf4j @Service @RequiredArgsConstructor public class ReservationDateService { @@ -33,12 +31,9 @@ public CreateReservationDateResponse createReservationDate(CreateReservationDate public void deleteReservationDate(Long id) { if (reservationRepository.countByReservationDateId(id) > 0) { - throw new ConflictException(ReservationDateErrorCode.RESERVATION_DATE_IN_USE); - } - int deletedCount = reservationDateRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("이미 삭제된 날짜의 삭제 요청이 들어왔습니다. dateId={}", id); + throw new ConflictException(ReservationDateErrors.RESERVATION_DATE_IN_USE); } + reservationDateRepository.deleteById(id); } public List getAllReservationDate() { diff --git a/src/main/java/roomescape/domain/reservationdate/admin/AdminReservationDateController.java b/src/main/java/roomescape/domain/reservationdate/admin/AdminReservationDateController.java new file mode 100644 index 0000000000..3aeeaf52c1 --- /dev/null +++ b/src/main/java/roomescape/domain/reservationdate/admin/AdminReservationDateController.java @@ -0,0 +1,60 @@ +package roomescape.domain.reservationdate.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +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.RestController; +import roomescape.domain.reservationdate.ReservationDateService; +import roomescape.domain.reservationdate.admin.dto.AdminReservationDateResponse; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateRequest; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateResponse; +import roomescape.support.auth.AdminRequestValidator; + +@RestController +@RequiredArgsConstructor +public class AdminReservationDateController { + + private final ReservationDateService reservationDateService; + private final AdminRequestValidator validator; + + @GetMapping("/admin/reservation-dates") + public ResponseEntity> getAllReservationDateForAdmin( + HttpServletRequest request + ) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + List response = reservationDateService.getAllReservationDateForAdmin(); + return ResponseEntity.ok(response); + } + + @PostMapping("/admin/reservation-dates") + public ResponseEntity createReservationDate( + HttpServletRequest httpServletRequest, + @Valid @RequestBody CreateReservationDateRequest createReservationDateRequest + ) { + if (validator.isUnauthorized(httpServletRequest)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + CreateReservationDateResponse response = reservationDateService + .createReservationDate(createReservationDateRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @DeleteMapping("/admin/reservation-dates/{id}") + public ResponseEntity deleteReservationDate(@PathVariable Long id, HttpServletRequest request) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + reservationDateService.deleteReservationDate(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/roomescape/domain/reservationdate/dto/AdminReservationDateResponse.java b/src/main/java/roomescape/domain/reservationdate/admin/dto/AdminReservationDateResponse.java similarity index 87% rename from src/main/java/roomescape/domain/reservationdate/dto/AdminReservationDateResponse.java rename to src/main/java/roomescape/domain/reservationdate/admin/dto/AdminReservationDateResponse.java index 073ed78f55..7d51fe6e27 100644 --- a/src/main/java/roomescape/domain/reservationdate/dto/AdminReservationDateResponse.java +++ b/src/main/java/roomescape/domain/reservationdate/admin/dto/AdminReservationDateResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.reservationdate.dto; +package roomescape.domain.reservationdate.admin.dto; import java.time.LocalDate; import roomescape.domain.reservationdate.ReservationDate; diff --git a/src/main/java/roomescape/domain/reservationdate/dto/CreateReservationDateRequest.java b/src/main/java/roomescape/domain/reservationdate/admin/dto/CreateReservationDateRequest.java similarity index 58% rename from src/main/java/roomescape/domain/reservationdate/dto/CreateReservationDateRequest.java rename to src/main/java/roomescape/domain/reservationdate/admin/dto/CreateReservationDateRequest.java index db3903782d..190d32c894 100644 --- a/src/main/java/roomescape/domain/reservationdate/dto/CreateReservationDateRequest.java +++ b/src/main/java/roomescape/domain/reservationdate/admin/dto/CreateReservationDateRequest.java @@ -1,9 +1,11 @@ -package roomescape.domain.reservationdate.dto; +package roomescape.domain.reservationdate.admin.dto; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import roomescape.domain.reservationdate.ReservationDate; public record CreateReservationDateRequest( + @NotNull(message = "예약 날짜는 필수 사항 입니다. 날짜를 선택해주세요.") LocalDate reservationDate ) { diff --git a/src/main/java/roomescape/domain/reservationdate/dto/CreateReservationDateResponse.java b/src/main/java/roomescape/domain/reservationdate/admin/dto/CreateReservationDateResponse.java similarity index 87% rename from src/main/java/roomescape/domain/reservationdate/dto/CreateReservationDateResponse.java rename to src/main/java/roomescape/domain/reservationdate/admin/dto/CreateReservationDateResponse.java index 727f589b45..d13bddfd9e 100644 --- a/src/main/java/roomescape/domain/reservationdate/dto/CreateReservationDateResponse.java +++ b/src/main/java/roomescape/domain/reservationdate/admin/dto/CreateReservationDateResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.reservationdate.dto; +package roomescape.domain.reservationdate.admin.dto; import java.time.LocalDate; import roomescape.domain.reservationdate.ReservationDate; diff --git a/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java b/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java index 3895af0f60..1207ccb831 100644 --- a/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java +++ b/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java @@ -3,6 +3,7 @@ import java.sql.PreparedStatement; import java.sql.Statement; import java.sql.Time; +import java.time.LocalTime; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -12,7 +13,7 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import roomescape.support.exception.InternalServerException; -import roomescape.support.exception.RoomescapeErrorCode; +import roomescape.support.exception.errors.RoomescapeErrors; @Repository @RequiredArgsConstructor @@ -24,6 +25,7 @@ public class JdbcReservationTimeRepository implements ReservationTimeRepository private static final String INSERT_SQL = "insert into reservation_time(start_at) values (?)"; private static final String FIND_ALL_SQL = "select id, start_at from reservation_time order by id"; private static final String FIND_BY_ID_SQL = "select id, start_at from reservation_time where id = ?"; + private static final String FIND_BY_TIME_SQL = "select id, start_at from reservation_time where start_at = ?"; private static final String DELETE_BY_ID_SQL = "delete from reservation_time where id = ?"; private final JdbcTemplate jdbcTemplate; @@ -59,6 +61,12 @@ public int deleteById(Long id) { return jdbcTemplate.update(DELETE_BY_ID_SQL, id); } + @Override + public Optional findByStartAt(LocalTime startAt) { + List result = jdbcTemplate.query(FIND_BY_TIME_SQL, reservationTimeRowMapper(), startAt); + return result.stream().findFirst(); + } + private RowMapper reservationTimeRowMapper() { return (rs, rowNum) -> ReservationTime.of( rs.getLong(COLUMN_ID), @@ -68,7 +76,7 @@ private RowMapper reservationTimeRowMapper() { private long extractId(KeyHolder keyHolder) { if (keyHolder.getKey() == null) { - throw new InternalServerException(RoomescapeErrorCode.INVALID_GENERATED_KEY); + throw new InternalServerException(RoomescapeErrors.INVALID_GENERATED_KEY); } return keyHolder.getKey().longValue(); } diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTime.java b/src/main/java/roomescape/domain/reservationtime/ReservationTime.java index 1ecb57b6dd..13356a52aa 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTime.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTime.java @@ -3,7 +3,7 @@ import java.time.LocalTime; import lombok.Getter; import roomescape.support.exception.BadRequestException; -import roomescape.support.exception.ReservationTimeErrorCode; +import roomescape.support.exception.errors.ReservationTimeErrors; @Getter public class ReservationTime { @@ -33,7 +33,11 @@ public static ReservationTime of(Long id, LocalTime startAt) { private static void validate(LocalTime startAt) { if (startAt == null) { - throw new BadRequestException(ReservationTimeErrorCode.INVALID_RESERVATION_TIME); + throw new BadRequestException(ReservationTimeErrors.INVALID_RESERVATION_TIME); } } + + public boolean isBefore(LocalTime compareTime) { + return startAt.isBefore(compareTime); + } } diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTimeController.java b/src/main/java/roomescape/domain/reservationtime/ReservationTimeController.java index 8b69e121d5..2cfd182c47 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTimeController.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTimeController.java @@ -1,62 +1,20 @@ package roomescape.domain.reservationtime; -import jakarta.servlet.http.HttpServletRequest; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; -import roomescape.support.auth.AdminRequestValidator; -import roomescape.domain.reservationtime.dto.CreateTimeRequest; -import roomescape.domain.reservationtime.dto.CreateTimeResponse; import roomescape.domain.reservationtime.dto.ReservationTimeAvailabilityResponse; -import roomescape.domain.reservationtime.dto.ReservationTimeResponse; @RestController @RequiredArgsConstructor public class ReservationTimeController { private final ReservationTimeService reservationTimeService; - private final AdminRequestValidator validator; - @GetMapping("/admin/times") - public ResponseEntity> getAllReservationTime(HttpServletRequest request) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - List response = reservationTimeService.getAllReservationTime(); - return ResponseEntity.ok(response); - } - - @PostMapping("/admin/times") - public ResponseEntity createReservationTime( - HttpServletRequest httpServletRequest, - @RequestBody CreateTimeRequest createTimeRequest - ) { - createTimeRequest.validate(); - if (validator.isUnauthorized(httpServletRequest)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - CreateTimeResponse response = reservationTimeService.createReservationTime(createTimeRequest); - return ResponseEntity.ok(response); - } - - @DeleteMapping("/admin/times/{id}") - public ResponseEntity deleteReservationTime(HttpServletRequest request, @PathVariable Long id) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - reservationTimeService.deleteReservationTime(id); - return ResponseEntity.ok().build(); - } - - @GetMapping("/times") + @GetMapping("/reservation-times/availability") public ResponseEntity> getReservationTimeAvailability( @RequestParam Long themeId, @RequestParam Long dateId diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java b/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java index 77f773a1ea..c9c4b55eb2 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java @@ -1,5 +1,6 @@ package roomescape.domain.reservationtime; +import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -12,4 +13,6 @@ public interface ReservationTimeRepository { Optional findById(Long id); int deleteById(Long id); + + Optional findByStartAt(LocalTime startAt); } diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java b/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java index f5590831f1..8ed2783ba7 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java @@ -4,23 +4,28 @@ import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import roomescape.domain.reservation.ReservationRepository; -import roomescape.support.exception.ConflictException; -import roomescape.domain.reservationtime.dto.CreateTimeRequest; -import roomescape.domain.reservationtime.dto.CreateTimeResponse; +import roomescape.domain.reservationdate.ReservationDateRepository; +import roomescape.domain.reservationtime.admin.dto.CreateTimeRequest; +import roomescape.domain.reservationtime.admin.dto.CreateTimeResponse; +import roomescape.domain.reservationtime.admin.dto.ReservationTimeResponse; import roomescape.domain.reservationtime.dto.ReservationTimeAvailabilityResponse; -import roomescape.domain.reservationtime.dto.ReservationTimeResponse; -import roomescape.support.exception.ReservationTimeErrorCode; +import roomescape.domain.theme.ThemeRepository; +import roomescape.support.exception.ConflictException; +import roomescape.support.exception.NotFoundException; +import roomescape.support.exception.errors.ReservationDateErrors; +import roomescape.support.exception.errors.ReservationTimeErrors; +import roomescape.support.exception.errors.ThemeErrors; -@Slf4j @Service @RequiredArgsConstructor public class ReservationTimeService { private final ReservationTimeRepository reservationTimeRepository; private final ReservationRepository reservationRepository; + private final ThemeRepository themeRepository; + private final ReservationDateRepository reservationDateRepository; public CreateTimeResponse createReservationTime(CreateTimeRequest request) { ReservationTime reservationTime = reservationTimeRepository.save(request.toEntity()); @@ -35,15 +40,13 @@ public List getAllReservationTime() { public void deleteReservationTime(Long id) { if (reservationRepository.countByTimeId(id) > 0) { - throw new ConflictException(ReservationTimeErrorCode.RESERVATION_TIME_IN_USE); - } - int deletedCount = reservationTimeRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("이미 삭제된 예약 시간 삭제 요청이 들어왔습니다. timeId={}", id); + throw new ConflictException(ReservationTimeErrors.RESERVATION_TIME_IN_USE); } + reservationTimeRepository.deleteById(id); } public List getReservationTimeAvailability(Long themeId, Long dateId) { + validateThemeAndDateExists(themeId, dateId); List allReservationTime = reservationTimeRepository.findAll(); Set reservedTimeIds = getReservedTimeIds(themeId, dateId); return allReservationTime.stream() @@ -54,6 +57,13 @@ public List getReservationTimeAvailability( .toList(); } + private void validateThemeAndDateExists(Long themeId, Long dateId) { + themeRepository.findById(themeId) + .orElseThrow(() -> new NotFoundException(ThemeErrors.THEME_NOT_EXIST)); + reservationDateRepository.findById(dateId) + .orElseThrow(() -> new NotFoundException(ReservationDateErrors.RESERVATION_DATE_NOT_EXIST)); + } + private Set getReservedTimeIds(Long themeId, Long dateId) { List reservedTimeIds = reservationRepository.findReservedTimes(themeId, dateId); return new HashSet<>(reservedTimeIds); diff --git a/src/main/java/roomescape/domain/reservationtime/admin/AdminReservationTimeController.java b/src/main/java/roomescape/domain/reservationtime/admin/AdminReservationTimeController.java new file mode 100644 index 0000000000..1a3f9d1ab3 --- /dev/null +++ b/src/main/java/roomescape/domain/reservationtime/admin/AdminReservationTimeController.java @@ -0,0 +1,57 @@ +package roomescape.domain.reservationtime.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +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.RestController; +import roomescape.domain.reservationtime.ReservationTimeService; +import roomescape.domain.reservationtime.admin.dto.CreateTimeRequest; +import roomescape.domain.reservationtime.admin.dto.CreateTimeResponse; +import roomescape.domain.reservationtime.admin.dto.ReservationTimeResponse; +import roomescape.support.auth.AdminRequestValidator; + +@RestController +@RequiredArgsConstructor +public class AdminReservationTimeController { + + private final ReservationTimeService reservationTimeService; + private final AdminRequestValidator validator; + + @GetMapping("/admin/times") + public ResponseEntity> getAllReservationTime(HttpServletRequest request) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + List response = reservationTimeService.getAllReservationTime(); + return ResponseEntity.ok(response); + } + + @PostMapping("/admin/times") + public ResponseEntity createReservationTime( + HttpServletRequest httpServletRequest, + @Valid @RequestBody CreateTimeRequest createTimeRequest + ) { + if (validator.isUnauthorized(httpServletRequest)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + CreateTimeResponse response = reservationTimeService.createReservationTime(createTimeRequest); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/admin/times/{id}") + public ResponseEntity deleteReservationTime(HttpServletRequest request, @PathVariable Long id) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + reservationTimeService.deleteReservationTime(id); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/roomescape/domain/reservationtime/admin/dto/CreateTimeRequest.java b/src/main/java/roomescape/domain/reservationtime/admin/dto/CreateTimeRequest.java new file mode 100644 index 0000000000..1322cca3aa --- /dev/null +++ b/src/main/java/roomescape/domain/reservationtime/admin/dto/CreateTimeRequest.java @@ -0,0 +1,15 @@ +package roomescape.domain.reservationtime.admin.dto; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalTime; +import roomescape.domain.reservationtime.ReservationTime; + +public record CreateTimeRequest( + @NotNull(message = "시간은 필수 사항 입니다. 시간을 선택해주세요.") + LocalTime startAt +) { + + public ReservationTime toEntity() { + return ReservationTime.createWithoutId(startAt); + } +} diff --git a/src/main/java/roomescape/domain/reservationtime/dto/CreateTimeResponse.java b/src/main/java/roomescape/domain/reservationtime/admin/dto/CreateTimeResponse.java similarity index 89% rename from src/main/java/roomescape/domain/reservationtime/dto/CreateTimeResponse.java rename to src/main/java/roomescape/domain/reservationtime/admin/dto/CreateTimeResponse.java index 295bb967f4..df406f6356 100644 --- a/src/main/java/roomescape/domain/reservationtime/dto/CreateTimeResponse.java +++ b/src/main/java/roomescape/domain/reservationtime/admin/dto/CreateTimeResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.reservationtime.dto; +package roomescape.domain.reservationtime.admin.dto; import com.fasterxml.jackson.annotation.JsonFormat; import java.time.LocalTime; diff --git a/src/main/java/roomescape/domain/reservationtime/dto/ReservationTimeResponse.java b/src/main/java/roomescape/domain/reservationtime/admin/dto/ReservationTimeResponse.java similarity index 90% rename from src/main/java/roomescape/domain/reservationtime/dto/ReservationTimeResponse.java rename to src/main/java/roomescape/domain/reservationtime/admin/dto/ReservationTimeResponse.java index d1c9e96357..7913a73f78 100644 --- a/src/main/java/roomescape/domain/reservationtime/dto/ReservationTimeResponse.java +++ b/src/main/java/roomescape/domain/reservationtime/admin/dto/ReservationTimeResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.reservationtime.dto; +package roomescape.domain.reservationtime.admin.dto; import com.fasterxml.jackson.annotation.JsonFormat; import java.time.LocalTime; diff --git a/src/main/java/roomescape/domain/reservationtime/dto/CreateTimeRequest.java b/src/main/java/roomescape/domain/reservationtime/dto/CreateTimeRequest.java deleted file mode 100644 index c06202d203..0000000000 --- a/src/main/java/roomescape/domain/reservationtime/dto/CreateTimeRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package roomescape.domain.reservationtime.dto; - -import java.time.LocalTime; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.support.exception.BadRequestException; -import roomescape.support.exception.ReservationTimeErrorCode; - -public record CreateTimeRequest( - LocalTime startAt -) { - - public void validate() { - if (startAt == null) { - throw new BadRequestException(ReservationTimeErrorCode.INVALID_RESERVATION_TIME); - } - } - - public ReservationTime toEntity() { - return ReservationTime.createWithoutId(startAt); - } -} diff --git a/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java b/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java index 992c2b561d..898a859af5 100644 --- a/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java +++ b/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java @@ -11,7 +11,7 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import roomescape.support.exception.InternalServerException; -import roomescape.support.exception.RoomescapeErrorCode; +import roomescape.support.exception.errors.RoomescapeErrors; @Repository @RequiredArgsConstructor @@ -75,7 +75,7 @@ private RowMapper themeRowMapper() { private long extractId(KeyHolder keyHolder) { if (keyHolder.getKey() == null) { - throw new InternalServerException(RoomescapeErrorCode.INVALID_GENERATED_KEY); + throw new InternalServerException(RoomescapeErrors.INVALID_GENERATED_KEY); } return keyHolder.getKey().longValue(); } diff --git a/src/main/java/roomescape/domain/theme/Theme.java b/src/main/java/roomescape/domain/theme/Theme.java index f87c4675a7..26f9e21328 100644 --- a/src/main/java/roomescape/domain/theme/Theme.java +++ b/src/main/java/roomescape/domain/theme/Theme.java @@ -1,16 +1,21 @@ package roomescape.domain.theme; import lombok.Getter; +import roomescape.support.exception.BadRequestException; +import roomescape.support.exception.errors.ThemeErrors; @Getter public class Theme { + private static final int MAX_NAME_LENGTH = 10; + private final Long id; private final String name; private final String content; private final String url; private Theme(Long id, String name, String content, String url) { + validateName(name); this.id = id; this.name = name; this.content = content; @@ -34,4 +39,10 @@ public static Theme createWithoutId(String name, String content, String url) { url ); } + + private void validateName(String name) { + if (name.length() > MAX_NAME_LENGTH) { + throw new BadRequestException(ThemeErrors.INVALID_THEME_NAME_LENGTH); + } + } } diff --git a/src/main/java/roomescape/domain/theme/ThemeController.java b/src/main/java/roomescape/domain/theme/ThemeController.java index 33183c6496..9afebccbb0 100644 --- a/src/main/java/roomescape/domain/theme/ThemeController.java +++ b/src/main/java/roomescape/domain/theme/ThemeController.java @@ -1,20 +1,10 @@ package roomescape.domain.theme; -import jakarta.servlet.http.HttpServletRequest; 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.RestController; -import roomescape.support.auth.AdminRequestValidator; -import roomescape.domain.theme.dto.AdminThemeResponse; -import roomescape.domain.theme.dto.CreateThemeRequest; -import roomescape.domain.theme.dto.CreateThemeResponse; import roomescape.domain.theme.dto.ThemeRankResponse; import roomescape.domain.theme.dto.ThemeResponse; @@ -23,35 +13,6 @@ public class ThemeController { private final ThemeService themeService; - private final AdminRequestValidator validator; - - @GetMapping("/admin/themes") - public ResponseEntity> getAllThemeForAdmin(HttpServletRequest request) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - List response = themeService.getAllThemeForAdmin(); - return ResponseEntity.ok(response); - } - - @PostMapping("/admin/themes") - public ResponseEntity createTheme(@RequestBody CreateThemeRequest createThemeRequest, - HttpServletRequest httpServletRequest) { - if (validator.isUnauthorized(httpServletRequest)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - CreateThemeResponse response = themeService.createTheme(createThemeRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @DeleteMapping("/admin/themes/{id}") - public ResponseEntity deleteTheme(@PathVariable Long id, HttpServletRequest request) { - if (validator.isUnauthorized(request)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - themeService.deleteTheme(id); - return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); - } @GetMapping("/themes") public ResponseEntity> getAllTheme() { diff --git a/src/main/java/roomescape/domain/theme/ThemeRepository.java b/src/main/java/roomescape/domain/theme/ThemeRepository.java index f92dcd8b2e..ba171e7dff 100644 --- a/src/main/java/roomescape/domain/theme/ThemeRepository.java +++ b/src/main/java/roomescape/domain/theme/ThemeRepository.java @@ -1,7 +1,7 @@ package roomescape.domain.theme; -import java.util.Optional; import java.util.List; +import java.util.Optional; public interface ThemeRepository { diff --git a/src/main/java/roomescape/domain/theme/ThemeService.java b/src/main/java/roomescape/domain/theme/ThemeService.java index a36741fe11..5aba2913e2 100644 --- a/src/main/java/roomescape/domain/theme/ThemeService.java +++ b/src/main/java/roomescape/domain/theme/ThemeService.java @@ -3,18 +3,16 @@ import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import roomescape.domain.reservation.ReservationRepository; -import roomescape.domain.theme.dto.AdminThemeResponse; -import roomescape.domain.theme.dto.CreateThemeRequest; -import roomescape.domain.theme.dto.CreateThemeResponse; +import roomescape.domain.theme.admin.dto.AdminThemeResponse; +import roomescape.domain.theme.admin.dto.CreateThemeRequest; +import roomescape.domain.theme.admin.dto.CreateThemeResponse; import roomescape.domain.theme.dto.ThemeRankResponse; import roomescape.domain.theme.dto.ThemeResponse; import roomescape.support.exception.ConflictException; -import roomescape.support.exception.ThemeErrorCode; +import roomescape.support.exception.errors.ThemeErrors; -@Slf4j @Service @RequiredArgsConstructor public class ThemeService { @@ -38,12 +36,9 @@ public CreateThemeResponse createTheme(CreateThemeRequest request) { public void deleteTheme(Long id) { if (reservationRepository.countByThemeId(id) > 0) { - throw new ConflictException(ThemeErrorCode.THEME_IN_USE); - } - int deletedCount = themeRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("삭제할 테마가 존재하지 않습니다. themeId = {}", id); + throw new ConflictException(ThemeErrors.THEME_IN_USE); } + themeRepository.deleteById(id); } public List getAllTheme() { diff --git a/src/main/java/roomescape/domain/theme/admin/AdminThemeController.java b/src/main/java/roomescape/domain/theme/admin/AdminThemeController.java new file mode 100644 index 0000000000..4a7b4657c2 --- /dev/null +++ b/src/main/java/roomescape/domain/theme/admin/AdminThemeController.java @@ -0,0 +1,57 @@ +package roomescape.domain.theme.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +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.RestController; +import roomescape.domain.theme.ThemeService; +import roomescape.domain.theme.admin.dto.AdminThemeResponse; +import roomescape.domain.theme.admin.dto.CreateThemeRequest; +import roomescape.domain.theme.admin.dto.CreateThemeResponse; +import roomescape.support.auth.AdminRequestValidator; + +@RestController +@RequiredArgsConstructor +public class AdminThemeController { + + private final ThemeService themeService; + private final AdminRequestValidator validator; + + @GetMapping("/admin/themes") + public ResponseEntity> getAllThemeForAdmin(HttpServletRequest request) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + List response = themeService.getAllThemeForAdmin(); + return ResponseEntity.ok(response); + } + + @PostMapping("/admin/themes") + public ResponseEntity createTheme( + @Valid @RequestBody CreateThemeRequest createThemeRequest, + HttpServletRequest httpServletRequest + ) { + if (validator.isUnauthorized(httpServletRequest)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + CreateThemeResponse response = themeService.createTheme(createThemeRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @DeleteMapping("/admin/themes/{id}") + public ResponseEntity deleteTheme(@PathVariable Long id, HttpServletRequest request) { + if (validator.isUnauthorized(request)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + themeService.deleteTheme(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/roomescape/domain/theme/dto/AdminThemeResponse.java b/src/main/java/roomescape/domain/theme/admin/dto/AdminThemeResponse.java similarity index 89% rename from src/main/java/roomescape/domain/theme/dto/AdminThemeResponse.java rename to src/main/java/roomescape/domain/theme/admin/dto/AdminThemeResponse.java index df52064a97..5d83efa74d 100644 --- a/src/main/java/roomescape/domain/theme/dto/AdminThemeResponse.java +++ b/src/main/java/roomescape/domain/theme/admin/dto/AdminThemeResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.theme.dto; +package roomescape.domain.theme.admin.dto; import roomescape.domain.theme.Theme; diff --git a/src/main/java/roomescape/domain/theme/admin/dto/CreateThemeRequest.java b/src/main/java/roomescape/domain/theme/admin/dto/CreateThemeRequest.java new file mode 100644 index 0000000000..e0b36b616e --- /dev/null +++ b/src/main/java/roomescape/domain/theme/admin/dto/CreateThemeRequest.java @@ -0,0 +1,24 @@ +package roomescape.domain.theme.admin.dto; + +import jakarta.validation.constraints.NotBlank; +import roomescape.domain.theme.Theme; + +public record CreateThemeRequest( + @NotBlank(message = "테마 이름은 비어있을 수 없습니다.") + String name, + + @NotBlank(message = "테마 내용은 비어있을 수 없습니다.") + String content, + + @NotBlank(message = "테마 URL은 비어있을 수 없습니다.") + String url +) { + + public Theme toEntity() { + return Theme.createWithoutId( + name, + content, + url + ); + } +} diff --git a/src/main/java/roomescape/domain/theme/dto/CreateThemeResponse.java b/src/main/java/roomescape/domain/theme/admin/dto/CreateThemeResponse.java similarity index 89% rename from src/main/java/roomescape/domain/theme/dto/CreateThemeResponse.java rename to src/main/java/roomescape/domain/theme/admin/dto/CreateThemeResponse.java index 3a40c0e6aa..0da8af07aa 100644 --- a/src/main/java/roomescape/domain/theme/dto/CreateThemeResponse.java +++ b/src/main/java/roomescape/domain/theme/admin/dto/CreateThemeResponse.java @@ -1,4 +1,4 @@ -package roomescape.domain.theme.dto; +package roomescape.domain.theme.admin.dto; import roomescape.domain.theme.Theme; diff --git a/src/main/java/roomescape/domain/theme/dto/CreateThemeRequest.java b/src/main/java/roomescape/domain/theme/dto/CreateThemeRequest.java deleted file mode 100644 index b9c1ffb3e0..0000000000 --- a/src/main/java/roomescape/domain/theme/dto/CreateThemeRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package roomescape.domain.theme.dto; - -import roomescape.domain.theme.Theme; - -public record CreateThemeRequest( - String name, - String content, - String url -) { - - public Theme toEntity() { - return Theme.createWithoutId( - name, - content, - url - ); - } -} diff --git a/src/main/java/roomescape/support/exception/BadRequestException.java b/src/main/java/roomescape/support/exception/BadRequestException.java index 0c8e35e7df..4f9e1bb24a 100644 --- a/src/main/java/roomescape/support/exception/BadRequestException.java +++ b/src/main/java/roomescape/support/exception/BadRequestException.java @@ -1,8 +1,10 @@ package roomescape.support.exception; +import roomescape.support.exception.errors.Errors; + public class BadRequestException extends RoomescapeException { - public BadRequestException(ErrorCode errorCode, Object... args) { - super(errorCode, args); + public BadRequestException(Errors errors, Object... args) { + super(errors, args); } } diff --git a/src/main/java/roomescape/support/exception/ConflictException.java b/src/main/java/roomescape/support/exception/ConflictException.java index 4ebc0e1cd5..8d3ca380a8 100644 --- a/src/main/java/roomescape/support/exception/ConflictException.java +++ b/src/main/java/roomescape/support/exception/ConflictException.java @@ -1,8 +1,10 @@ package roomescape.support.exception; +import roomescape.support.exception.errors.Errors; + public class ConflictException extends RoomescapeException { - public ConflictException(ErrorCode errorCode, Object... args) { - super(errorCode, args); + public ConflictException(Errors errors, Object... args) { + super(errors, args); } } diff --git a/src/main/java/roomescape/support/exception/ErrorCode.java b/src/main/java/roomescape/support/exception/ErrorCode.java deleted file mode 100644 index 6e5dd03b8f..0000000000 --- a/src/main/java/roomescape/support/exception/ErrorCode.java +++ /dev/null @@ -1,8 +0,0 @@ -package roomescape.support.exception; - -public interface ErrorCode { - - String getMessage(); - - String getCode(); -} diff --git a/src/main/java/roomescape/support/exception/ErrorResponse.java b/src/main/java/roomescape/support/exception/ErrorResponse.java index c1596dc001..9471d4fa32 100644 --- a/src/main/java/roomescape/support/exception/ErrorResponse.java +++ b/src/main/java/roomescape/support/exception/ErrorResponse.java @@ -2,15 +2,25 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import roomescape.support.exception.errors.Errors; public record ErrorResponse( String code, String message ) { - public static ResponseEntity of(HttpStatus httpStatus, ErrorCode errorCode) { + public static ResponseEntity of(HttpStatus httpStatus, RoomescapeException exception) { return ResponseEntity.status(httpStatus) - .body(new ErrorResponse(errorCode.getCode(), errorCode.getMessage())); + .body(new ErrorResponse(exception.getErrors().getCode(), exception.getMessage())); } + public static ResponseEntity of(HttpStatus httpStatus, Errors errors) { + return ResponseEntity.status(httpStatus) + .body(new ErrorResponse(errors.getCode(), errors.getMessage())); + } + + public static ResponseEntity of(HttpStatus httpStatus, Errors errors, String message) { + return ResponseEntity.status(httpStatus) + .body(new ErrorResponse(errors.getCode(), message)); + } } diff --git a/src/main/java/roomescape/support/exception/GlobalExceptionHandler.java b/src/main/java/roomescape/support/exception/GlobalExceptionHandler.java index 20c118ffff..378aa3b6bd 100644 --- a/src/main/java/roomescape/support/exception/GlobalExceptionHandler.java +++ b/src/main/java/roomescape/support/exception/GlobalExceptionHandler.java @@ -1,35 +1,86 @@ package roomescape.support.exception; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.ObjectError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import roomescape.support.exception.errors.RoomescapeErrors; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BadRequestException.class) - public ResponseEntity handleBadRequestException(BadRequestException exception) { - return ErrorResponse.of(HttpStatus.BAD_REQUEST, exception.getErrorCode()); + public ResponseEntity handleBadRequestException(BadRequestException e) { + return ErrorResponse.of(HttpStatus.BAD_REQUEST, e); } @ExceptionHandler(NotFoundException.class) - public ResponseEntity handleNotFoundException(NotFoundException exception) { - return ErrorResponse.of(HttpStatus.NOT_FOUND, exception.getErrorCode()); + public ResponseEntity handleNotFoundException(NotFoundException e) { + return ErrorResponse.of(HttpStatus.NOT_FOUND, e); } @ExceptionHandler(ConflictException.class) - public ResponseEntity handleConflictException(ConflictException exception) { - return ErrorResponse.of(HttpStatus.CONFLICT, exception.getErrorCode()); + public ResponseEntity handleConflictException(ConflictException e) { + return ErrorResponse.of(HttpStatus.CONFLICT, e); } @ExceptionHandler(InternalServerException.class) - public ResponseEntity handleInternalServerException(InternalServerException exception) { - return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, exception.getErrorCode()); + public ResponseEntity handleInternalServerException(InternalServerException e) { + return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, e); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getAllErrors() + .stream() + .findFirst() + .map(ObjectError::getDefaultMessage) + .orElse(RoomescapeErrors.INPUT_VALIDATION_ERROR.getMessage()); + + return ErrorResponse.of(HttpStatus.BAD_REQUEST, RoomescapeErrors.INPUT_VALIDATION_ERROR, message); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e + ) { + return ErrorResponse.of(HttpStatus.BAD_REQUEST, RoomescapeErrors.INPUT_FORMAT_ERROR); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String message = e.getConstraintViolations() + .stream() + .findFirst() + .map(ConstraintViolation::getMessage) + .orElse(RoomescapeErrors.INPUT_VALIDATION_ERROR.getMessage()); + + return ErrorResponse.of(HttpStatus.BAD_REQUEST, RoomescapeErrors.INPUT_VALIDATION_ERROR, message); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e + ) { + return ErrorResponse.of(HttpStatus.BAD_REQUEST, RoomescapeErrors.REQUIRED_PARAMETER_MISSING); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e + ) { + return ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED, RoomescapeErrors.METHOD_NOT_ALLOWED); } @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception exception) { - return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, RoomescapeErrorCode.INTERNAL_SERVER_ERROR); + public ResponseEntity handleException(Exception e) { + return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, RoomescapeErrors.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/roomescape/support/exception/InternalServerException.java b/src/main/java/roomescape/support/exception/InternalServerException.java index 4b16606ff2..b45387fc80 100644 --- a/src/main/java/roomescape/support/exception/InternalServerException.java +++ b/src/main/java/roomescape/support/exception/InternalServerException.java @@ -1,8 +1,10 @@ package roomescape.support.exception; +import roomescape.support.exception.errors.Errors; + public class InternalServerException extends RoomescapeException { - public InternalServerException(ErrorCode errorCode, Object... args) { - super(errorCode, args); + public InternalServerException(Errors errors, Object... args) { + super(errors, args); } } diff --git a/src/main/java/roomescape/support/exception/NotFoundException.java b/src/main/java/roomescape/support/exception/NotFoundException.java index 4e5e7f61b8..acdf318fa4 100644 --- a/src/main/java/roomescape/support/exception/NotFoundException.java +++ b/src/main/java/roomescape/support/exception/NotFoundException.java @@ -1,8 +1,10 @@ package roomescape.support.exception; +import roomescape.support.exception.errors.Errors; + public class NotFoundException extends RoomescapeException { - public NotFoundException(ErrorCode errorCode, Object... args) { - super(errorCode, args); + public NotFoundException(Errors errors, Object... args) { + super(errors, args); } } diff --git a/src/main/java/roomescape/support/exception/ReservationDateErrorCode.java b/src/main/java/roomescape/support/exception/ReservationDateErrorCode.java deleted file mode 100644 index dbf8be10e2..0000000000 --- a/src/main/java/roomescape/support/exception/ReservationDateErrorCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package roomescape.support.exception; - -import lombok.Getter; - -@Getter -public enum ReservationDateErrorCode implements ErrorCode { - - RESERVATION_DATE_NOT_EXIST("존재하지 않는 날짜 입니다."), - RESERVATION_DATE_IN_USE("이미 예약이 존재하는 날짜는 삭제할 수 없습니다."), - ; - - private final String message; - - ReservationDateErrorCode(String message) { - this.message = message; - } - - @Override - public String getCode() { - return name(); - } -} diff --git a/src/main/java/roomescape/support/exception/ReservationErrorCode.java b/src/main/java/roomescape/support/exception/ReservationErrorCode.java deleted file mode 100644 index 97d11b0a34..0000000000 --- a/src/main/java/roomescape/support/exception/ReservationErrorCode.java +++ /dev/null @@ -1,23 +0,0 @@ -package roomescape.support.exception; - -import lombok.Getter; - -@Getter -public enum ReservationErrorCode implements ErrorCode { - - INVALID_RESERVATION_NAME("이름은 비어 있을 수 없습니다."), - INVALID_RESERVATION_DATE("날짜는 필수입니다."), - RESERVATION_NOT_FOUND("존재하지 않는 예약건 입니다"), - ; - - private final String message; - - ReservationErrorCode(String message) { - this.message = message; - } - - @Override - public String getCode() { - return name(); - } -} diff --git a/src/main/java/roomescape/support/exception/RoomescapeErrorCode.java b/src/main/java/roomescape/support/exception/RoomescapeErrorCode.java deleted file mode 100644 index cec2288ea0..0000000000 --- a/src/main/java/roomescape/support/exception/RoomescapeErrorCode.java +++ /dev/null @@ -1,23 +0,0 @@ -package roomescape.support.exception; - -import lombok.Getter; - -@Getter -public enum RoomescapeErrorCode implements ErrorCode { - - BAD_REQUEST("잘못된 요청입니다."), - INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다"), - INVALID_GENERATED_KEY("생성 키를 조회할 수 없습니다."), - ; - - private final String message; - - RoomescapeErrorCode(String message) { - this.message = message; - } - - @Override - public String getCode() { - return name(); - } -} diff --git a/src/main/java/roomescape/support/exception/RoomescapeException.java b/src/main/java/roomescape/support/exception/RoomescapeException.java index b1a22e21e5..1eec4e317a 100644 --- a/src/main/java/roomescape/support/exception/RoomescapeException.java +++ b/src/main/java/roomescape/support/exception/RoomescapeException.java @@ -1,14 +1,15 @@ package roomescape.support.exception; import lombok.Getter; +import roomescape.support.exception.errors.Errors; @Getter public abstract class RoomescapeException extends RuntimeException { - private final ErrorCode errorCode; + private final Errors errors; - public RoomescapeException(ErrorCode errorCode, Object... args) { - super(String.format(errorCode.getMessage(), args)); - this.errorCode = errorCode; + public RoomescapeException(Errors errors, Object... args) { + super(String.format(errors.getMessage(), args)); + this.errors = errors; } } diff --git a/src/main/java/roomescape/support/exception/errors/Errors.java b/src/main/java/roomescape/support/exception/errors/Errors.java new file mode 100644 index 0000000000..28c45d8560 --- /dev/null +++ b/src/main/java/roomescape/support/exception/errors/Errors.java @@ -0,0 +1,8 @@ +package roomescape.support.exception.errors; + +public interface Errors { + + String getMessage(); + + String getCode(); +} diff --git a/src/main/java/roomescape/support/exception/errors/ReservationDateErrors.java b/src/main/java/roomescape/support/exception/errors/ReservationDateErrors.java new file mode 100644 index 0000000000..697da3d75f --- /dev/null +++ b/src/main/java/roomescape/support/exception/errors/ReservationDateErrors.java @@ -0,0 +1,24 @@ +package roomescape.support.exception.errors; + +import lombok.Getter; + +@Getter +public enum ReservationDateErrors implements Errors { + + RESERVATION_DATE_NOT_EXIST("존재하지 않는 날짜 입니다."), + RESERVATION_DATE_IN_USE("이미 예약이 존재하는 날짜는 삭제할 수 없습니다."), + RESERVATION_DATE_MUST_BE_TODAY_OR_LATER("예약 날짜는 오늘 이후여야 합니다. 오늘 날짜:%s"), + PAST_RESERVATION_DATE_CANNOT_BE_DELETED("예전 예약은 삭제할 수 없습니다. 오늘 날짜:%s"), + ; + + private final String message; + + ReservationDateErrors(String message) { + this.message = message; + } + + @Override + public String getCode() { + return name(); + } +} diff --git a/src/main/java/roomescape/support/exception/errors/ReservationErrors.java b/src/main/java/roomescape/support/exception/errors/ReservationErrors.java new file mode 100644 index 0000000000..3600df9357 --- /dev/null +++ b/src/main/java/roomescape/support/exception/errors/ReservationErrors.java @@ -0,0 +1,26 @@ +package roomescape.support.exception.errors; + +import lombok.Getter; + +@Getter +public enum ReservationErrors implements Errors { + + INVALID_RESERVATION_NAME("이름은 비어 있을 수 없습니다."), + INVALID_RESERVATION_NAME_LENGTH("이름은 10자 이하여야 합니다."), + INVALID_RESERVATION_DATE("날짜는 필수입니다."), + RESERVATION_NOT_FOUND("존재하지 않는 예약건 입니다"), + DUPLICATED_RESERVATION("중복 예약입니다. 예약 정보를 다시 확인해주세요."), + INVALID_RESERVATION_UPDATE_REQUEST("수정할 예약 날짜 또는 시간을 입력해주세요."), + ; + + private final String message; + + ReservationErrors(String message) { + this.message = message; + } + + @Override + public String getCode() { + return name(); + } +} diff --git a/src/main/java/roomescape/support/exception/ReservationTimeErrorCode.java b/src/main/java/roomescape/support/exception/errors/ReservationTimeErrors.java similarity index 57% rename from src/main/java/roomescape/support/exception/ReservationTimeErrorCode.java rename to src/main/java/roomescape/support/exception/errors/ReservationTimeErrors.java index 5f5586e6e1..0a7ecbffa1 100644 --- a/src/main/java/roomescape/support/exception/ReservationTimeErrorCode.java +++ b/src/main/java/roomescape/support/exception/errors/ReservationTimeErrors.java @@ -1,19 +1,21 @@ -package roomescape.support.exception; +package roomescape.support.exception.errors; import lombok.Getter; @Getter -public enum ReservationTimeErrorCode implements ErrorCode { +public enum ReservationTimeErrors implements Errors { INVALID_RESERVATION_TIME("시간은 필수입니다."), INVALID_RESERVATION_TIME_FORMAT("시간은 HH:MM 형식이어야 합니다."), RESERVATION_TIME_NOT_EXIST("존재하지 않는 예약 시간대 입니다."), RESERVATION_TIME_IN_USE("이미 예약이 존재하는 시간대는 삭제할 수 없습니다."), + RESERVATION_TIME_SHOULD_BE_NOW_OR_LATER("예약 시간은 현재 이후여야 합니다. 현재 시각:%s"), + PAST_RESERVATION_TiME_CANNOT_BE_DELETED("현재보다 이전 시간 예약을 삭제할 수 없습니다. 현재 시각:%s"), ; private final String message; - ReservationTimeErrorCode(String message) { + ReservationTimeErrors(String message) { this.message = message; } diff --git a/src/main/java/roomescape/support/exception/errors/RoomescapeErrors.java b/src/main/java/roomescape/support/exception/errors/RoomescapeErrors.java new file mode 100644 index 0000000000..bfeff25b4a --- /dev/null +++ b/src/main/java/roomescape/support/exception/errors/RoomescapeErrors.java @@ -0,0 +1,26 @@ +package roomescape.support.exception.errors; + +import lombok.Getter; + +@Getter +public enum RoomescapeErrors implements Errors { + + INPUT_FORMAT_ERROR("입력 형식이 올바르지 않습니다. 날짜는 yyyy-MM-dd, 시간은 HH:mm 형식으로 입력해주세요."), + INPUT_VALIDATION_ERROR("입력 검증 오류가 발생했습니다."), + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), + INVALID_GENERATED_KEY("생성 키를 조회할 수 없습니다."), + REQUIRED_PARAMETER_MISSING("필수 요청 파라미터가 누락되었습니다."), + METHOD_NOT_ALLOWED("요청한 경로에서 지원하지 않는 HTTP 메서드입니다."), + ; + + private final String message; + + RoomescapeErrors(String message) { + this.message = message; + } + + @Override + public String getCode() { + return name(); + } +} diff --git a/src/main/java/roomescape/support/exception/ThemeErrorCode.java b/src/main/java/roomescape/support/exception/errors/ThemeErrors.java similarity index 65% rename from src/main/java/roomescape/support/exception/ThemeErrorCode.java rename to src/main/java/roomescape/support/exception/errors/ThemeErrors.java index df48ff6a5a..be5defc4dc 100644 --- a/src/main/java/roomescape/support/exception/ThemeErrorCode.java +++ b/src/main/java/roomescape/support/exception/errors/ThemeErrors.java @@ -1,18 +1,19 @@ -package roomescape.support.exception; +package roomescape.support.exception.errors; import lombok.Getter; @Getter -public enum ThemeErrorCode implements ErrorCode { +public enum ThemeErrors implements Errors { INVALID_THEME("테마는 필수입니다."), + INVALID_THEME_NAME_LENGTH("테마 이름은 10자 이하여야 합니다."), THEME_NOT_EXIST("존재하지 않는 테마 입니다."), THEME_IN_USE("이미 예약이 존재하는 테마는 삭제할 수 없습니다."), ; private final String message; - ThemeErrorCode(String message) { + ThemeErrors(String message) { this.message = message; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f43642f39e..46defae0db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,3 +5,5 @@ spring: path: /h2-console datasource: url: jdbc:h2:mem:database + +token: "" diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index dcca1197af..b91d33d4c3 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -6,15 +6,17 @@ VALUES ('10:00'), ('16:00'); INSERT INTO reservation_date (date) -VALUES ('2026-05-01'), - ('2026-05-02'), - ('2026-05-03'), - ('2026-05-04'), - ('2026-05-05'), - ('2026-05-06'), - ('2026-05-07'), - ('2026-05-08'), - ('2026-05-09'); +VALUES ('2026-05-14'), + ('2026-05-15'), + ('2026-05-16'), + ('2026-06-01'), + ('2026-06-02'), + ('2026-06-03'), + ('2026-06-04'), + ('2026-06-05'), + ('2026-06-10'), + ('2026-06-11'), + ('2026-06-12'); INSERT INTO theme (name, content, url) VALUES ('공포', '오금이 저리는 공포입니다.', '/themes/scary'), @@ -32,6 +34,7 @@ VALUES ('공포', '오금이 저리는 공포입니다.', '/themes/scary'), INSERT INTO reservation (name, date_id, time_id, theme_id) VALUES ('보예', 1, 1, 1), + ('보예', 9, 1, 1), ('이산', 1, 2, 1), ('나무', 2, 1, 2), ('피즈', 2, 3, 2), diff --git a/src/main/resources/static/css/times.css b/src/main/resources/static/css/times.css index 60ae33c02c..9e7d04ef14 100644 --- a/src/main/resources/static/css/times.css +++ b/src/main/resources/static/css/times.css @@ -35,7 +35,8 @@ a { } button, -input { +input, +select { font: inherit; } @@ -345,6 +346,131 @@ input { border-color: var(--blue); } +.form-message.success { + color: #047857; +} + +.form-message.error { + color: #dc2626; +} + +.reservation-lookup-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 20px; +} + +.lookup-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.lookup-controls input, +.reservation-edit-form select { + min-height: 46px; + padding: 0 14px; + border: 1px solid #cfd8e6; + border-radius: 12px; + background: #fff; + color: var(--text); + outline: none; +} + +.lookup-controls input:focus, +.reservation-edit-form select:focus { + border-color: var(--blue); +} + +.user-reservation-list { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.user-reservation-card, +.empty-state { + padding: 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: #fbfcff; +} + +.user-reservation-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: start; +} + +.user-reservation-main h3 { + margin: 10px 0 6px; + font-size: 1.08rem; +} + +.user-reservation-main p, +.empty-state { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.reservation-date-time { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + color: var(--muted); +} + +.reservation-date-time strong { + color: var(--text); +} + +.reservation-card-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 14px; +} + +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + padding: 0 18px; + border-radius: 12px; + border: 1px solid #fecaca; + background: #fff; + color: #dc2626; + font-size: 0.96rem; + font-weight: 500; + cursor: pointer; +} + +.reservation-edit-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.reservation-edit-form label { + display: flex; + flex-direction: column; + gap: 8px; + color: var(--muted); + font-size: 0.9rem; +} + +.reservation-edit-form .reservation-card-actions { + grid-column: 1 / -1; +} + .full-width { width: 100%; } @@ -492,7 +618,22 @@ input { .primary-button, .secondary-button, - .secondary-link { + .secondary-link, + .danger-button { width: 100%; } + + .lookup-controls, + .user-reservation-main, + .reservation-edit-form { + grid-template-columns: 1fr; + } + + .reservation-date-time { + align-items: flex-start; + } + + .reservation-card-actions { + flex-direction: column; + } } diff --git a/src/main/resources/static/js/times.js b/src/main/resources/static/js/times.js index 9259fb1706..fc7763eeb8 100644 --- a/src/main/resources/static/js/times.js +++ b/src/main/resources/static/js/times.js @@ -5,7 +5,12 @@ document.addEventListener("DOMContentLoaded", () => { rankedThemes: [], times: [], selectedThemeId: null, - selectedDateId: null + selectedDateId: null, + userReservationName: "", + userReservations: [], + editingReservationId: null, + editDateByReservationId: {}, + editTimesByReservationId: {} }; const modalTriggers = document.querySelectorAll("[data-modal-open]"); @@ -22,6 +27,19 @@ document.addEventListener("DOMContentLoaded", () => { const showTimesButton = document.getElementById("show-times-button"); const resetSelectionButton = document.getElementById("reset-selection-button"); const statusStrip = document.getElementById("status-strip"); + const userReservationSearchForm = document.getElementById("user-reservation-search-form"); + const userReservationNameInput = document.getElementById("user-reservation-name"); + const userReservationMessage = document.getElementById("user-reservation-message"); + const userReservationList = document.getElementById("user-reservation-list"); + + document.querySelectorAll("[data-scroll-target]").forEach((button) => { + button.addEventListener("click", () => { + document.getElementById(button.dataset.scrollTarget)?.scrollIntoView({ + behavior: "smooth", + block: "start" + }); + }); + }); modalTriggers.forEach((trigger) => { trigger.addEventListener("click", () => { @@ -67,6 +85,15 @@ document.addEventListener("DOMContentLoaded", () => { return date.slice(5).replace("-", "."); } + function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + function findTheme() { return state.themes.find((theme) => theme.id === state.selectedThemeId); } @@ -133,6 +160,154 @@ document.addEventListener("DOMContentLoaded", () => { `).join(""); } + function renderUserReservations() { + if (!state.userReservationName) { + userReservationList.innerHTML = ""; + return; + } + + if (state.userReservations.length === 0) { + userReservationList.innerHTML = ` +
+ ${escapeHtml(state.userReservationName)}님의 예약이 없습니다. +
+ `; + return; + } + + userReservationList.innerHTML = state.userReservations.map((reservation) => { + const isEditing = state.editingReservationId === reservation.reservationId; + const selectedStartWhen = state.editDateByReservationId[reservation.reservationId] || reservation.date.startWhen; + const editTimes = state.editTimesByReservationId[reservation.reservationId] || []; + const dateOptions = state.dates.map((date) => ` + + `).join(""); + const timeOptions = buildEditTimeOptions(reservation, editTimes); + + return ` +
+
+
+ 예약 번호 ${reservation.reservationId} +

${escapeHtml(reservation.theme.name)}

+

${escapeHtml(reservation.theme.content)}

+
+
+ ${reservation.date.startWhen} + ${reservation.time.startAt} +
+
+ ${isEditing ? ` +
+ + +
+ + +
+
+ ` : ` +
+ + +
+ `} +
+ `; + }).join(""); + + bindUserReservationEvents(); + } + + function buildEditTimeOptions(reservation, times) { + const options = new Map(); + options.set(reservation.time.startAt, reservation.time.startAt); + times + .filter((time) => time.available || time.startAt === reservation.time.startAt) + .forEach((time) => options.set(time.startAt, time.startAt)); + + return [...options.values()].map((startAt) => ` + + `).join(""); + } + + function bindUserReservationEvents() { + userReservationList.querySelectorAll("[data-cancel-id]").forEach((button) => { + button.addEventListener("click", async () => { + await cancelReservation(Number(button.dataset.cancelId)); + }); + }); + + userReservationList.querySelectorAll("[data-edit-id]").forEach((button) => { + button.addEventListener("click", async () => { + const reservation = findUserReservation(Number(button.dataset.editId)); + state.editingReservationId = reservation.reservationId; + state.editDateByReservationId[reservation.reservationId] = reservation.date.startWhen; + renderUserReservations(); + await loadEditTimes(reservation, reservation.date.id); + }); + }); + + userReservationList.querySelectorAll("[data-edit-cancel]").forEach((button) => { + button.addEventListener("click", () => { + state.editingReservationId = null; + renderUserReservations(); + }); + }); + + userReservationList.querySelectorAll("[data-edit-date]").forEach((select) => { + select.addEventListener("change", async () => { + const reservation = findUserReservation(Number(select.dataset.id)); + const selectedOption = select.options[select.selectedIndex]; + state.editDateByReservationId[reservation.reservationId] = selectedOption.value; + await loadEditTimes(reservation, Number(selectedOption.dataset.dateId)); + }); + }); + + userReservationList.querySelectorAll("[data-edit-form]").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(form); + await updateReservation(Number(form.dataset.id), { + startWhen: formData.get("startWhen"), + startAt: formData.get("startAt") + }); + }); + }); + } + + function findUserReservation(id) { + return state.userReservations.find((reservation) => reservation.reservationId === id); + } + + function setUserReservationMessage(text, type = "") { + userReservationMessage.textContent = text; + userReservationMessage.className = "form-message"; + if (type) { + userReservationMessage.classList.add(type); + } + } + + async function parseResponse(response) { + const text = await response.text(); + if (!text) { + return null; + } + return JSON.parse(text); + } + async function fetchJson(url) { const response = await fetch(url, { headers: { Accept: "application/json" } }); if (!response.ok) { @@ -141,6 +316,76 @@ document.addEventListener("DOMContentLoaded", () => { return response.json(); } + async function loadUserReservations(name) { + const response = await fetch(`/reservations?name=${encodeURIComponent(name)}`, { + headers: { Accept: "application/json" } + }); + const result = await parseResponse(response); + + if (!response.ok) { + throw new Error(result?.message || "예약 목록을 불러오지 못했습니다."); + } + + state.userReservationName = result.name; + state.userReservations = result.reservation; + state.editingReservationId = null; + state.editDateByReservationId = {}; + state.editTimesByReservationId = {}; + renderUserReservations(); + } + + async function cancelReservation(id) { + if (!window.confirm("예약을 취소하시겠습니까?")) { + return; + } + + const response = await fetch(`/reservations/${id}`, { + method: "DELETE", + headers: { Accept: "application/json" } + }); + const result = await parseResponse(response); + + if (!response.ok) { + setUserReservationMessage(result?.message || "예약 취소 중 문제가 발생했습니다.", "error"); + return; + } + + setUserReservationMessage("예약이 취소되었습니다.", "success"); + await loadUserReservations(state.userReservationName); + } + + async function updateReservation(id, payload) { + const response = await fetch(`/reservations/${id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + body: JSON.stringify(payload) + }); + const result = await parseResponse(response); + + if (!response.ok) { + setUserReservationMessage(result?.message || "예약 변경 중 문제가 발생했습니다.", "error"); + return; + } + + setUserReservationMessage("예약이 변경되었습니다.", "success"); + await loadUserReservations(state.userReservationName); + } + + async function loadEditTimes(reservation, dateId) { + try { + const times = await fetchJson( + `/reservation-times/availability?themeId=${reservation.theme.id}&dateId=${dateId}` + ); + state.editTimesByReservationId[reservation.reservationId] = times; + renderUserReservations(); + } catch (error) { + setUserReservationMessage("변경 가능한 시간을 불러오지 못했습니다.", "error"); + } + } + async function loadInitialData() { const [themes, dates, rankedThemes] = await Promise.all([ fetchJson("/themes"), @@ -166,7 +411,9 @@ document.addEventListener("DOMContentLoaded", () => { const selectedTheme = findTheme(); const selectedDate = findDate(); - state.times = await fetchJson(`/times?themeId=${state.selectedThemeId}&dateId=${state.selectedDateId}`); + state.times = await fetchJson( + `/reservation-times/availability?themeId=${state.selectedThemeId}&dateId=${state.selectedDateId}` + ); renderTimes(); selectedThemeInput.value = String(state.selectedThemeId); selectedDateInput.value = String(state.selectedDateId); @@ -225,6 +472,9 @@ document.addEventListener("DOMContentLoaded", () => { message.textContent = result.name + "님의 예약이 완료되었습니다."; message.classList.add("success"); + userReservationNameInput.value = result.name; + await loadUserReservations(result.name); + window.setTimeout(() => { window.location.reload(); }, 600); @@ -234,6 +484,22 @@ document.addEventListener("DOMContentLoaded", () => { } }); + userReservationSearchForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const name = new FormData(userReservationSearchForm).get("name").trim(); + if (!name) { + setUserReservationMessage("예약자 이름을 입력해 주세요.", "error"); + return; + } + + try { + setUserReservationMessage(""); + await loadUserReservations(name); + } catch (error) { + setUserReservationMessage(error.message, "error"); + } + }); + loadInitialData().catch(() => { updateStatus("초기 데이터를 불러오지 못했습니다."); }); diff --git a/src/main/resources/templates/times.html b/src/main/resources/templates/times.html index df11ce4801..30b547a45a 100644 --- a/src/main/resources/templates/times.html +++ b/src/main/resources/templates/times.html @@ -15,6 +15,7 @@ ROOMESCAPE @@ -91,6 +92,26 @@

예약 정보 입력

+
+
+
+ MY RESERVATIONS +

내 예약 조회

+

예약자 이름으로 예약 목록을 확인하고, 취소하거나 날짜와 시간을 변경할 수 있습니다.

+
+
+
+ +
+ + +
+

+
+
+
+ diff --git a/src/test/java/roomescape/domain/reservation/ReservationControllerTest.java b/src/test/java/roomescape/domain/reservation/ReservationControllerTest.java new file mode 100644 index 0000000000..c6e1ac2111 --- /dev/null +++ b/src/test/java/roomescape/domain/reservation/ReservationControllerTest.java @@ -0,0 +1,223 @@ +package roomescape.domain.reservation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.reservation.dto.CreateReservationRequest; +import roomescape.domain.reservation.dto.CreateReservationResponse; +import roomescape.domain.reservation.dto.CreateReservationResponse.ThemePayload; +import roomescape.domain.reservation.dto.UpdateReservationRequest; +import roomescape.domain.reservation.dto.UserReservationResponse; +import roomescape.domain.reservationdate.ReservationDate; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.support.exception.NotFoundException; +import roomescape.support.exception.errors.ReservationErrors; + +@WebMvcTest(ReservationController.class) +class ReservationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReservationService reservationService; + + @Test + @DisplayName("예약 생성 요청과 응답을 확인한다.") + void createReservation() throws Exception { + // given + CreateReservationRequest request = new CreateReservationRequest( + "보예", + 1L, + 2L, + 3L + ); + CreateReservationResponse response = new CreateReservationResponse( + 10L, + "보예", + LocalDate.of(2026, 5, 20), + LocalTime.of(10, 0), + ThemePayload.from(Theme.of(1L, "공포", "무섭다", "theme-url")) + ); + given(reservationService.createReservation(any(CreateReservationRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/reservations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(10)) + .andExpect(jsonPath("$.name").value("보예")) + .andExpect(jsonPath("$.date").value("2026-05-20")) + .andExpect(jsonPath("$.time").value("10:00")) + .andExpect(jsonPath("$.theme.name").value("공포")) + .andExpect(jsonPath("$.theme.content").value("무섭다")) + .andExpect(jsonPath("$.theme.url").value("theme-url")); + } + + @Test + @DisplayName("필수 파라미터가 누락되었을 때 예외가 발생한다.") + void createWrongParameterReservation() throws Exception { + // given + CreateReservationRequest request = new CreateReservationRequest( + "보예", + 1L, + null, + 3L + ); + + // when & then + mockMvc.perform(post("/reservations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INPUT_VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("시간은 필수 선택 사항 입니다. 시간을 선택해주세요.")); + } + + @Test + @DisplayName("예약자 이름 조회의 요청과 응답을 확인한다.") + void getUserReservations() throws Exception { + // given + String name = "보예"; + UserReservationResponse response = UserReservationResponse.of("보예", + List.of(Reservation.of(1L, "보예", + ReservationDate.of(1L, LocalDate.of(2026, 5, 17)), + ReservationTime.of(1L, LocalTime.of(10, 10)), + Theme.of(1L, "공포", "아무서워", "theme-url") + ) + ) + ); + given(reservationService.getUserReservations(name)).willReturn(response); + + // when & then + mockMvc.perform(get("/reservations") + .contentType(MediaType.APPLICATION_JSON) + .param("name", name)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("보예")) + .andExpect(jsonPath("$.reservation[0].reservationId").value(1L)) + .andExpect(jsonPath("$.reservation[0].date.startWhen").value("2026-05-17")) + .andExpect(jsonPath("$.reservation[0].time.startAt").value("10:10")) + .andExpect(jsonPath("$.reservation[0].theme.name").value("공포")) + .andExpect(jsonPath("$.reservation[0].theme.content").value("아무서워")) + .andExpect(jsonPath("$.reservation[0].theme.url").value("theme-url")); + } + + @Test + @DisplayName("예약자 이름 조회 시 이름이 누락되면 예외가 발생한다.") + void getUserReservationsWithoutName() throws Exception { + // given & when & then + mockMvc.perform(get("/reservations") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("REQUIRED_PARAMETER_MISSING")) + .andExpect(jsonPath("$.message").value("필수 요청 파라미터가 누락되었습니다.")); + } + + @Test + @DisplayName("예약 삭제의 정상 요청과 응답을 확인한다.") + void cancelUserReservation() throws Exception { + // given + Long id = 1L; + + // when & then + mockMvc.perform(delete("/reservations/{id}", id)) + .andExpect(status().isNoContent()); + + verify(reservationService).cancelUserReservation(id); + } + + @Test + @DisplayName("예약 수정의 정상 요청과 응답을 확인한다.") + void updateReservation() throws Exception { + // given + Long id = 1L; + UpdateReservationRequest request = new UpdateReservationRequest( + LocalDate.of(2026, 5, 18), + LocalTime.of(14, 30) + ); + + // when & then + mockMvc.perform(patch("/reservations/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()); + + verify(reservationService).updateReservation(id, request); + } + + @Test + @DisplayName("예약 수정 시 잘못된 요청 형식이면 예외가 발생한다.") + void updateReservationWithInvalidFormat() throws Exception { + // given + Long id = 1L; + + // when & then + mockMvc.perform(patch("/reservations/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "startWhen": "2026/05/18", + "startAt": "14:30" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INPUT_FORMAT_ERROR")) + .andExpect(jsonPath("$.message").value("입력 형식이 올바르지 않습니다. 날짜는 yyyy-MM-dd, 시간은 HH:mm 형식으로 입력해주세요.")); + } + + @Test + @DisplayName("수정할 예약이 없으면 예외가 발생한다.") + void updateReservationWhenReservationNotFound() throws Exception { + // given + Long id = 999L; + UpdateReservationRequest request = new UpdateReservationRequest( + LocalDate.of(2026, 5, 18), + LocalTime.of(14, 30) + ); + willThrow(new NotFoundException(ReservationErrors.RESERVATION_NOT_FOUND)) + .given(reservationService) + .updateReservation(id, request); + + // when & then + mockMvc.perform(patch("/reservations/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESERVATION_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("존재하지 않는 예약건 입니다")); + } + + @Test + @DisplayName("예약 삭제 시 id를 누락한 경우 예외가 발생한다.") + void cancelUserReservationWithoutId() throws Exception { + // given & when & then + mockMvc.perform(delete("/reservations")) + .andExpect(status().isMethodNotAllowed()); + } +} diff --git a/src/test/java/roomescape/domain/reservation/ReservationIntegrationTest.java b/src/test/java/roomescape/domain/reservation/ReservationIntegrationTest.java new file mode 100644 index 0000000000..cef2b4f3d3 --- /dev/null +++ b/src/test/java/roomescape/domain/reservation/ReservationIntegrationTest.java @@ -0,0 +1,237 @@ +package roomescape.domain.reservation; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ReservationIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("예약 생성을 end-to-end로 확인한다.") + void createReservation() { + Long themeId = saveTheme("공포"); + Long dateId = saveDate("2026-06-01"); + Long timeId = saveTime("10:00"); + + String request = """ + { + "name": "보예", + "dateId": %d, + "timeId": %d, + "themeId": %d + } + """.formatted(dateId, timeId, themeId); + + given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/reservations") + .then().log().all() + .statusCode(201) + .body("name", is("보예")) + .body("date", is("2026-06-01")) + .body("time", is("10:00")) + .body("theme.name", is("공포")) + .body("theme.content", is("무서운 테마")) + .body("theme.url", is("theme-url")); + + given() + .contentType(ContentType.JSON) + .param("name", "보예") + .when().get("/reservations") + .then() + .statusCode(200) + .body("name", is("보예")) + .body("reservation", hasSize(1)); + } + + @Test + @DisplayName("예약 생성 시 시간 필드가 누락되었을 경우 400 에러가 발생한다.") + void createReservationWithoutTimeId() { + Long themeId = saveTheme("공포"); + Long dateId = saveDate("2026-06-01"); + + String request = """ + { + "name": "보예", + "dateId": %d, + "themeId": %d + } + """.formatted(dateId, themeId); + + given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/reservations") + .then().log().all() + .statusCode(400) + .body("code", is("INPUT_VALIDATION_ERROR")) + .body("message", is("시간은 필수 선택 사항 입니다. 시간을 선택해주세요.")); + } + + @Test + @DisplayName("예약자 이름으로 예약 조회를 end-to-end로 확인한다.") + void getUserReservations() { + saveReservation("보예", "2026-06-01", "10:00", "공포"); + + given().log().all() + .contentType(ContentType.JSON) + .param("name", "보예") + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("name", is("보예")) + .body("reservation[0].date.startWhen", is("2026-06-01")) + .body("reservation[0].time.startAt", is("10:00")) + .body("reservation[0].theme.name", is("공포")); + } + + @Test + @DisplayName("예약 조회 시 이름 파라미터가 누락되었을 경우 400 에러가 발생한다.") + void getUserReservationsWithoutName() { + given().log().all() + .contentType(ContentType.JSON) + .when().get("/reservations") + .then().log().all() + .statusCode(400) + .body("code", is("REQUIRED_PARAMETER_MISSING")) + .body("message", is("필수 요청 파라미터가 누락되었습니다.")); + } + + @Test + @DisplayName("예약 삭제를 end-to-end로 확인한다.") + void deleteUserReservation() { + Long reservationId = saveReservation("보예", "2026-06-01", "10:00", "공포"); + + given().log().all() + .contentType(ContentType.JSON) + .when().delete("/reservations/{id}", reservationId) + .then().log().all() + .statusCode(204); + + given() + .contentType(ContentType.JSON) + .param("name", "보예") + .when().get("/reservations") + .then() + .statusCode(200) + .body("name", is("보예")) + .body("reservation", hasSize(0)); + } + + @Test + @DisplayName("예약 수정을 end-to-end로 확인한다.") + void updateReservation() { + Long reservationId = saveReservation("보예", "2026-06-01", "10:00", "공포"); + saveDate("2026-06-02"); + saveTime("11:00"); + + String request = """ + { + "startWhen": "2026-06-02", + "startAt": "11:00" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().patch("/reservations/{id}", reservationId) + .then().log().all() + .statusCode(204); + + given() + .contentType(ContentType.JSON) + .param("name", "보예") + .when().get("/reservations") + .then() + .statusCode(200) + .body("reservation[0].date.startWhen", is("2026-06-02")) + .body("reservation[0].time.startAt", is("11:00")); + } + + private Long saveReservation(String name, String date, String time, String themeName) { + Long themeId = saveTheme(themeName); + Long dateId = saveDate(date); + Long timeId = saveTime(time); + + jdbcTemplate.update( + "INSERT INTO reservation(name, date_id, time_id, theme_id) VALUES (?, ?, ?, ?)", + name, + dateId, + timeId, + themeId + ); + + return jdbcTemplate.queryForObject( + "SELECT id FROM reservation WHERE name = ?", + Long.class, + name + ); + } + + private Long saveTheme(String themeName) { + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + themeName, + "무서운 테마", + "theme-url" + ); + return jdbcTemplate.queryForObject( + "SELECT id FROM theme WHERE name = ?", + Long.class, + themeName + ); + } + + private Long saveDate(String date) { + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + date + ); + return jdbcTemplate.queryForObject( + "SELECT id FROM reservation_date WHERE date = ?", + Long.class, + date + ); + } + + private Long saveTime(String time) { + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + time + ); + return jdbcTemplate.queryForObject( + "SELECT id FROM reservation_time WHERE start_at = ?", + Long.class, + time + ":00" + ); + } +} diff --git a/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java b/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java index 6a56c1579b..b0827f29eb 100644 --- a/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java +++ b/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java @@ -2,18 +2,27 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.BDDAssertions.tuple; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import java.time.Clock; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.ZoneId; import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import roomescape.domain.reservation.admin.dto.ReservationResponse; import roomescape.domain.reservation.dto.CreateReservationRequest; import roomescape.domain.reservation.dto.CreateReservationResponse; -import roomescape.domain.reservation.dto.ReservationResponse; +import roomescape.domain.reservation.dto.UpdateReservationRequest; +import roomescape.domain.reservation.dto.UserReservationResponse; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; +import roomescape.support.exception.BadRequestException; import roomescape.support.exception.RoomescapeException; import roomescape.support.fake.FakeReservationDateRepository; import roomescape.support.fake.FakeReservationRepository; @@ -22,50 +31,67 @@ class ReservationServiceTest { + private static final ZoneId ZONE_ID = ZoneId.systemDefault(); + private FakeReservationRepository reservationRepository; + private FakeReservationTimeRepository reservationTimeRepository; + private FakeReservationDateRepository reservationDateRepository; + private FakeThemeRepository themeRepository; + + @BeforeEach + void setUp() { + reservationRepository = new FakeReservationRepository(); + reservationTimeRepository = new FakeReservationTimeRepository(); + reservationDateRepository = new FakeReservationDateRepository(); + themeRepository = new FakeThemeRepository(); + } + @Test - void 존재하는_예약_시간으로_예약을_생성한다() { + @DisplayName("존재하는 예약 시간으로 예약을 생성한다.") + void createReservationWithExistingReservationTime() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - FakeReservationDateRepository reservationDateRepository = new FakeReservationDateRepository(); - FakeThemeRepository themeRepository = new FakeThemeRepository(); - ReservationTime reservationTime = ReservationTime.of(1L, LocalTime.of(10, 0)); - ReservationDate reservationDate = ReservationDate.of(2L, LocalDate.of(2026, 5, 4)); - Theme theme = Theme.of(3L, "공포", "무서운 테마", "theme-url"); - reservationTimeRepository.reservationTime = reservationTime; - reservationDateRepository.reservationDate = reservationDate; - themeRepository.theme = theme; + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = ReservationTime.createWithoutId(LocalTime.of(10, 0)); + ReservationTime savedReservationTime = reservationTimeRepository.save(reservationTime); + ReservationDate reservationDate = ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)); + ReservationDate savedReservationDate = reservationDateRepository.save(reservationDate); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); ReservationService reservationService = new ReservationService( reservationRepository, reservationTimeRepository, reservationDateRepository, - themeRepository + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + savedReservationDate.getId(), + savedReservationTime.getId(), + theme.getId() ); - CreateReservationRequest request = new CreateReservationRequest("보예", 2L, 1L, 3L); // when CreateReservationResponse response = reservationService.createReservation(request); // then assertSoftly(softly -> { - assertThat(response.id()).isEqualTo(1L); assertThat(response.name()).isEqualTo("보예"); - assertThat(response.date()).isEqualTo(LocalDate.of(2026, 5, 4)); + assertThat(response.date()).isEqualTo(LocalDate.of(2026, 5, 13)); assertThat(response.time()).isEqualTo(LocalTime.of(10, 0)); assertThat(response.theme().name()).isEqualTo("공포"); - assertThat(reservationRepository.savedReservation.getTime()).isEqualTo(reservationTime); - assertThat(reservationRepository.savedReservation.getTheme()).isEqualTo(theme); }); } @Test - void 존재하지_않는_예약_시간으로_예약을_생성하면_예외가_발생한다() { + @DisplayName("존재하지 않는 예약 시간으로 예약을 생성하면 예외가 발생한다.") + void throwExceptionWhenCreatingReservationWithNonExistentReservationTime() { // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); ReservationService reservationService = new ReservationService( - new FakeReservationRepository(), - new FakeReservationTimeRepository(), - new FakeReservationDateRepository(), - new FakeThemeRepository() + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now ); CreateReservationRequest request = new CreateReservationRequest("보예", 1L, 1L, 1L); @@ -76,19 +102,27 @@ class ReservationServiceTest { } @Test - void 존재하지_않는_테마로_예약을_생성하면_예외가_발생한다() { + @DisplayName("존재하지 않는 테마로 예약을 생성하면 예외가 발생한다.") + void throwExceptionWhenCreatingReservationWithNonExistentTheme() { // given - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - FakeReservationDateRepository reservationDateRepository = new FakeReservationDateRepository(); - reservationTimeRepository.reservationTime = ReservationTime.of(1L, LocalTime.of(10, 0)); - reservationDateRepository.reservationDate = ReservationDate.of(2L, LocalDate.of(2026, 5, 4)); + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0))); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13))); ReservationService reservationService = new ReservationService( - new FakeReservationRepository(), + reservationRepository, reservationTimeRepository, reservationDateRepository, - new FakeThemeRepository() + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + reservationDate.getId(), + reservationTime.getId(), + 3L ); - CreateReservationRequest request = new CreateReservationRequest("보예", 2L, 1L, 3L); // when & then assertThatThrownBy(() -> reservationService.createReservation(request)) @@ -97,24 +131,31 @@ class ReservationServiceTest { } @Test - void 예약_목록을_조회한다() { + @DisplayName("예약 목록을 전체 조회한다.") + void getAllReservations() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - reservationRepository.findAllResult = List.of( - Reservation.of( - 1L, + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationDate savedReservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운테마", "theme-url")); + reservationRepository.save( + Reservation.createWithoutId( "보예", - ReservationDate.of(3L, LocalDate.of(2026, 5, 4)), - ReservationTime.of(2L, LocalTime.of(10, 0)), - Theme.of(4L, "공포", "무서운 테마", "theme-url") + savedReservationDate, + reservationTime, + theme ) ); ReservationService reservationService = new ReservationService( reservationRepository, reservationTimeRepository, - new FakeReservationDateRepository(), - new FakeThemeRepository() + reservationDateRepository, + themeRepository, + now ); // when @@ -123,13 +164,630 @@ class ReservationServiceTest { // then assertSoftly(softly -> { assertThat(responses).hasSize(1); - assertThat(responses.getFirst().id()).isEqualTo(1L); assertThat(responses.getFirst().name()).isEqualTo("보예"); - assertThat(responses.getFirst().date()).isEqualTo(LocalDate.of(2026, 5, 4)); - assertThat(responses.getFirst().time().id()).isEqualTo(2L); + assertThat(responses.getFirst().date()).isEqualTo(LocalDate.of(2026, 5, 13)); + assertThat(responses.getFirst().time().id()).isEqualTo(reservationTime.getId()); assertThat(responses.getFirst().time().startAt()).isEqualTo(LocalTime.of(10, 0)); - assertThat(responses.getFirst().theme().id()).isEqualTo(4L); + assertThat(responses.getFirst().theme().id()).isEqualTo(theme.getId()); assertThat(responses.getFirst().theme().name()).isEqualTo("공포"); }); } + + @Test + @DisplayName("사용자가 이름으로 예약을 조회한다.") + void getUserReservationsByName() { + // given + String name = "보예짱"; + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationDate firstReservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + ReservationDate secondReservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 14)) + ); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운테마", "theme-url")); + reservationRepository.save( + Reservation.createWithoutId( + name, + secondReservationDate, + reservationTime, + theme + ) + ); + reservationRepository.save( + Reservation.createWithoutId( + name, + firstReservationDate, + reservationTime, + theme + ) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + + // when + UserReservationResponse userReservations = reservationService.getUserReservations(name); + + // then + assertSoftly(softly -> { + assertThat(userReservations.reservation().size()).isEqualTo(2); + assertThat(userReservations.name()).isEqualTo("보예짱"); + assertThat(userReservations.reservation()) + .extracting( + reservationPayload -> reservationPayload.date().startWhen(), + reservationPayload -> reservationPayload.time().startAt(), + reservationPayload -> reservationPayload.theme().name() + ) + .containsExactly( + tuple(LocalDate.of(2026, 5, 14), LocalTime.of(10, 0), "공포"), + tuple(LocalDate.of(2026, 5, 13), LocalTime.of(10, 0), "공포") + ); + }); + } + + @Test + @DisplayName("오늘보다 이전 날짜는 예약할 수 없다.") + void throwExceptionWhenCreatingReservationBeforeToday() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(14, 0)) + ); + ReservationDate beforeToday = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 10)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + beforeToday.getId(), + reservationTime.getId(), + theme.getId() + ); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("예약 날짜는 오늘 이후여야 합니다. 오늘 날짜:" + LocalDate.of(2026, 5, 12)); + } + + @Test + @DisplayName("오늘 예약일 경우 현재 시간 이전은 예약할 수 없다.") + void throwExceptionWhenCreatingReservationBeforeCurrentTimeOnToday() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime beforeNow = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(12, 59)) + ); + ReservationDate today = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 12)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + today.getId(), + beforeNow.getId(), + theme.getId() + ); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("예약 시간은 현재 이후여야 합니다. 현재 시각:" + LocalTime.of(13, 0)); + } + + @Test + @DisplayName("오늘 예약이지만 현재 시간은 예약할 수 있다.") + void createReservationAtCurrentTimeOnToday() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime nowTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(13, 0)) + ); + ReservationDate today = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 12)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + today.getId(), + nowTime.getId(), + theme.getId() + ); + + // when + CreateReservationResponse response = reservationService.createReservation(request); + Reservation reservation = reservationRepository.findById(response.id()).orElseThrow(); + + // then + assertSoftly(softly -> { + assertThat(response.id()).isEqualTo(reservation.getId()); + assertThat(response.date()).isEqualTo(LocalDate.of(2026, 5, 12)); + assertThat(response.time()).isEqualTo(LocalTime.of(13, 0)); + assertThat(response.name()).isEqualTo("보예"); + assertThat(response.theme().name()).isEqualTo("공포"); + assertThat(response.theme().content()).isEqualTo("무서운 테마"); + assertThat(response.theme().url()).isEqualTo("theme-url"); + } + ); + } + + @Test + @DisplayName("날짜가 오늘 이후이고 현재 시간보다 이전이면 정상 예약 된다.") + void createReservationAfterTodayEvenIfTimeIsBeforeNow() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationDate today = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + today.getId(), + reservationTime.getId(), + theme.getId() + ); + + // when + CreateReservationResponse response = reservationService.createReservation(request); + Reservation reservation = reservationRepository.findById(response.id()).orElseThrow(); + + // then + assertSoftly(softly -> { + assertThat(response.id()).isEqualTo(reservation.getId()); + assertThat(response.date()).isEqualTo(LocalDate.of(2026, 5, 13)); + assertThat(response.time()).isEqualTo(LocalTime.of(10, 0)); + assertThat(response.name()).isEqualTo("보예"); + assertThat(response.theme().name()).isEqualTo("공포"); + assertThat(response.theme().content()).isEqualTo("무서운 테마"); + assertThat(response.theme().url()).isEqualTo("theme-url"); + } + ); + } + + @Test + @DisplayName("중복된 예약은 예외가 발생한다.") + void throwExceptionWhenCreatingDuplicatedReservation() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = ReservationTime.createWithoutId(LocalTime.of(10, 0)); + ReservationTime savedReservationTime = reservationTimeRepository.save(reservationTime); + ReservationDate reservationDate = ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)); + ReservationDate savedReservationDate = reservationDateRepository.save(reservationDate); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + CreateReservationRequest request = new CreateReservationRequest( + "보예", + savedReservationDate.getId(), + savedReservationTime.getId(), + theme.getId() + ); + reservationService.createReservation(request); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("중복 예약입니다. 예약 정보를 다시 확인해주세요."); + } + + @Test + @DisplayName("사용자는 미래 예약을 삭제할 수 있다.") + void deleteFutureReservationForUser() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + + // when + reservationService.cancelUserReservation(savedReservation.getId()); + + // then + assertThat(reservationRepository.findById(savedReservation.getId())).isEmpty(); + } + + @Test + @DisplayName("사용자는 이미 시간이 지난 예약을 삭제할 수 없다.") + void throwExceptionWhenUserDeletesPastTimeReservation() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(12, 59)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 12)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + + // when & then + assertThatThrownBy(() -> reservationService.cancelUserReservation(savedReservation.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("현재보다 이전 시간 예약을 삭제할 수 없습니다. 현재 시각:" + LocalTime.of(13, 0)); + } + + @Test + @DisplayName("사용자는 이미 날짜가 지난 예약을 삭제할 수 없다.") + void throwExceptionWhenUserDeletesPastDateReservation() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(12, 59)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 11)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + + // when & then + assertThatThrownBy(() -> reservationService.cancelUserReservation(savedReservation.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("예전 예약은 삭제할 수 없습니다. 오늘 날짜:" + LocalDate.of(2026, 5, 12)); + } + + @Test + @DisplayName("사용자가 존재하지 않는 예약을 삭제하면 예외가 발생한다.") + void throwExceptionWhenUserDeletesNonExistentReservation() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + + // when & then + assertThatThrownBy(() -> reservationService.cancelUserReservation(1L)) + .isInstanceOf(RoomescapeException.class) + .hasMessage("존재하지 않는 예약건 입니다"); + } + + @Test + @DisplayName("예약 날짜와 시간을 수정한다.") + void updateReservationDateAndTime() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime beforeReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationTime afterReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(15, 0)) + ); + ReservationDate beforeReservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + ReservationDate afterReservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 14)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", beforeReservationDate, beforeReservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest( + afterReservationDate.getDate(), + afterReservationTime.getStartAt() + ); + + // when + reservationService.updateReservation(savedReservation.getId(), request); + + // then + Reservation updatedReservation = reservationRepository.findById(savedReservation.getId()).orElseThrow(); + assertSoftly(softly -> { + assertThat(updatedReservation.getDate().getDate()).isEqualTo(LocalDate.of(2026, 5, 14)); + assertThat(updatedReservation.getTime().getStartAt()).isEqualTo(LocalTime.of(15, 0)); + assertThat(updatedReservation.getName()).isEqualTo("보예"); + assertThat(updatedReservation.getTheme().getName()).isEqualTo("공포"); + }); + } + + @Test + @DisplayName("예약 시간만 수정한다.") + void updateReservationTimeOnly() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime beforeReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationTime afterReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(15, 0)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, beforeReservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(null, afterReservationTime.getStartAt()); + + // when + reservationService.updateReservation(savedReservation.getId(), request); + Reservation updatedReservation = reservationRepository.findById(savedReservation.getId()).orElseThrow(); + + // then + assertSoftly(softly -> { + assertThat(updatedReservation.getDate().getDate()).isEqualTo(LocalDate.of(2026, 5, 13)); + assertThat(updatedReservation.getTime().getStartAt()).isEqualTo(LocalTime.of(15, 0)); + }); + } + + @Test + @DisplayName("존재하지 않는 예약을 수정하면 예외가 발생한다.") + void throwExceptionWhenUpdatingNonExistentReservation() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(LocalDate.of(2026, 5, 13), LocalTime.of(10, 0)); + + // when & then + assertThatThrownBy(() -> reservationService.updateReservation(1L, request)) + .isInstanceOf(RoomescapeException.class) + .hasMessage("존재하지 않는 예약건 입니다"); + } + + @Test + @DisplayName("존재하지 않는 예약 날짜로 수정하면 예외가 발생한다.") + void throwExceptionWhenUpdatingReservationWithNonExistentDate() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(LocalDate.of(2026, 5, 14), null); + + // when & then + assertThatThrownBy(() -> reservationService.updateReservation(savedReservation.getId(), request)) + .isInstanceOf(RoomescapeException.class) + .hasMessage("존재하지 않는 날짜 입니다."); + } + + @Test + @DisplayName("존재하지 않는 예약 시간으로 수정하면 예외가 발생한다.") + void throwExceptionWhenUpdatingReservationWithNonExistentTime() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(null, LocalTime.of(15, 0)); + + // when & then + assertThatThrownBy(() -> reservationService.updateReservation(savedReservation.getId(), request)) + .isInstanceOf(RoomescapeException.class) + .hasMessage("존재하지 않는 예약 시간대 입니다."); + } + + @Test + @DisplayName("오늘보다 이전 날짜로 예약을 수정할 수 없다.") + void throwExceptionWhenUpdatingReservationToDateBeforeToday() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(15, 0)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + ReservationDate beforeToday = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 11)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(beforeToday.getDate(), null); + + // when & then + assertThatThrownBy(() -> reservationService.updateReservation(savedReservation.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("예약 날짜는 오늘 이후여야 합니다. 오늘 날짜:" + LocalDate.of(2026, 5, 12)); + } + + @Test + @DisplayName("오늘 예약을 현재 시간보다 이전으로 수정할 수 없다.") + void throwExceptionWhenUpdatingReservationToTimeBeforeNowOnToday() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(15, 0)) + ); + ReservationTime beforeNow = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(12, 59)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 12)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(null, beforeNow.getStartAt()); + + // when & then + assertThatThrownBy(() -> reservationService.updateReservation(savedReservation.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("예약 시간은 현재 이후여야 합니다. 현재 시각:" + LocalTime.of(13, 0)); + } + + @Test + @DisplayName("중복된 예약으로 수정하면 예외가 발생한다.") + void throwExceptionWhenUpdatingReservationToDuplicatedSchedule() { + // given + Clock now = fixedClockAt(LocalDateTime.of(2026, 5, 12, 13, 0)); + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationTime otherReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(15, 0)) + ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 13)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + Reservation savedReservation = reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, reservationTime, theme) + ); + reservationRepository.save( + Reservation.createWithoutId("파랑", reservationDate, otherReservationTime, theme) + ); + ReservationService reservationService = new ReservationService( + reservationRepository, + reservationTimeRepository, + reservationDateRepository, + themeRepository, + now + ); + UpdateReservationRequest request = new UpdateReservationRequest(null, otherReservationTime.getStartAt()); + + // when & then + assertThatThrownBy(() -> reservationService.updateReservation(savedReservation.getId(), request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("중복 예약입니다. 예약 정보를 다시 확인해주세요."); + } + + private Clock fixedClockAt(LocalDateTime dateTime) { + return Clock.fixed(dateTime.atZone(ZONE_ID).toInstant(), ZONE_ID); + } } diff --git a/src/test/java/roomescape/domain/reservation/ReservationTest.java b/src/test/java/roomescape/domain/reservation/ReservationTest.java index c1c695ff49..c4f8e442d8 100644 --- a/src/test/java/roomescape/domain/reservation/ReservationTest.java +++ b/src/test/java/roomescape/domain/reservation/ReservationTest.java @@ -6,6 +6,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationtime.ReservationTime; @@ -15,7 +16,8 @@ class ReservationTest { @Test - void id가_없는_예약을_생성한다() { + @DisplayName("id가 없는 예약을 생성한다.") + void createReservationWithoutId() { // given String name = "보예"; ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); @@ -37,7 +39,8 @@ class ReservationTest { } @Test - void id를_부여한_예약을_생성한다() { + @DisplayName("id를 부여한 예약을 생성한다.") + void createReservationWithId() { // given ReservationTime time = ReservationTime.createWithoutId(LocalTime.of(15, 40)); ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); @@ -70,7 +73,8 @@ class ReservationTest { } @Test - void DB에서_조회한_예약을_생성한다() { + @DisplayName("DB에서 조회한 예약을 생성한다.") + void createReservationLoadedFromDatabase() { // given long id = 1L; String name = "보예"; @@ -93,7 +97,8 @@ class ReservationTest { } @Test - void 이름이_null이면_예외가_발생한다() { + @DisplayName("이름이 null이면 예외가 발생한다.") + void throwExceptionWhenNameIsNull() { // given String name = null; ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); @@ -107,7 +112,8 @@ class ReservationTest { } @Test - void 이름이_공백이면_예외가_발생한다() { + @DisplayName("이름이 공백이면 예외가 발생한다.") + void throwExceptionWhenNameIsBlank() { // given String name = " "; ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); @@ -121,7 +127,23 @@ class ReservationTest { } @Test - void 날짜가_null이면_예외가_발생한다() { + @DisplayName("이름이 10자를 초과하면 예외가 발생한다.") + void throwExceptionWhenNameExceedsTenCharacters() { + // given + String name = "보예보예보예보예보예보"; + ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); + ReservationTime time = ReservationTime.createWithoutId(LocalTime.of(15, 40)); + Theme theme = Theme.of(1L, "공포", "무서운 테마", "theme-url"); + + // when & then + assertThatThrownBy(() -> Reservation.createWithoutId(name, date, time, theme)) + .isInstanceOf(RoomescapeException.class) + .hasMessage("이름은 10자 이하여야 합니다."); + } + + @Test + @DisplayName("날짜가 null이면 예외가 발생한다.") + void throwExceptionWhenDateIsNull() { // given String name = "보예"; ReservationDate date = null; @@ -135,7 +157,8 @@ class ReservationTest { } @Test - void 예약_시간이_null이면_예외가_발생한다() { + @DisplayName("예약 시간이 null이면 예외가 발생한다.") + void throwExceptionWhenReservationTimeIsNull() { // given String name = "보예"; ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); @@ -149,7 +172,8 @@ class ReservationTest { } @Test - void 테마가_null이면_예외가_발생한다() { + @DisplayName("테마가 null이면 예외가 발생한다.") + void throwExceptionWhenThemeIsNull() { // given String name = "보예"; ReservationDate date = ReservationDate.createWithoutId(LocalDate.of(2023, 8, 5)); diff --git a/src/test/java/roomescape/domain/reservation/admin/AdminReservationControllerTest.java b/src/test/java/roomescape/domain/reservation/admin/AdminReservationControllerTest.java new file mode 100644 index 0000000000..d7e4c6e3e6 --- /dev/null +++ b/src/test/java/roomescape/domain/reservation/admin/AdminReservationControllerTest.java @@ -0,0 +1,122 @@ +package roomescape.domain.reservation.admin; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.reservation.ReservationService; +import roomescape.domain.reservation.admin.dto.ReservationResponse; +import roomescape.domain.reservation.admin.dto.ReservationResponse.ReservationTimePayload; +import roomescape.domain.reservation.admin.dto.ReservationResponse.ThemePayload; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.support.auth.AdminRequestValidator; + +@WebMvcTest(AdminReservationController.class) +class AdminReservationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReservationService reservationService; + + @MockitoBean + private AdminRequestValidator validator; + + @Test + @DisplayName("관리자가 전체 예약 조회 시 요청과 응답을 확인한다.") + void getAllReservation() throws Exception { + // given + ReservationResponse response = new ReservationResponse( + 1L, + "보예", + LocalDate.of(2026, 5, 10), + ReservationTimePayload.from(ReservationTime.of(2L, LocalTime.of(10, 10))), + ThemePayload.from(Theme.of(3L, "공포", "으악 무서워!", "theme-url")) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(reservationService.getAllReservations()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/admin/reservations") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].name").value("보예")) + .andExpect(jsonPath("$[0].date").value("2026-05-10")) + .andExpect(jsonPath("$[0].time.id").value(2)) + .andExpect(jsonPath("$[0].time.startAt").value("10:10")) + .andExpect(jsonPath("$[0].theme.id").value(3)) + .andExpect(jsonPath("$[0].theme.name").value("공포")) + .andExpect(jsonPath("$[0].theme.content").value("으악 무서워!")) + .andExpect(jsonPath("$[0].theme.url").value("theme-url")); + + verify(reservationService).getAllReservations(); + } + + @Test + @DisplayName("관리자 인증에 실패하면 전체 예약 조회 시 401을 반환한다.") + void getAllReservationWhenUnauthorized() throws Exception { + // given + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(get("/admin/reservations") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(reservationService, never()).getAllReservations(); + } + + @Test + @DisplayName("관리자가 예약 삭제 시 요청과 응답을 확인한다.") + void cancelReservation() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(delete("/admin/reservations/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isNoContent()); + + verify(reservationService).cancelReservationByAdmin(id); + } + + @Test + @DisplayName("관리자 인증에 실패하면 예약 삭제 시 401을 반환한다.") + void cancelReservationWhenUnauthorized() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(delete("/admin/reservations/{id}", id) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(reservationService, never()).cancelReservationByAdmin(id); + } +} diff --git a/src/test/java/roomescape/domain/reservation/admin/AdminReservationIntegrationTest.java b/src/test/java/roomescape/domain/reservation/admin/AdminReservationIntegrationTest.java new file mode 100644 index 0000000000..a1b33f4390 --- /dev/null +++ b/src/test/java/roomescape/domain/reservation/admin/AdminReservationIntegrationTest.java @@ -0,0 +1,133 @@ +package roomescape.domain.reservation.admin; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AdminReservationIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${token}") + private String adminToken; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("관리자의 예약 전체 조회를 end-to-end로 확인한다.") + void getAllReservation() { + saveThemeDateTimeAndReservation("보예"); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/reservations") + .then().log().all() + .statusCode(200) + .body("[0].name", is("보예")) + .body("[0].date", is("2026-06-01")) + .body("[0].time.startAt", is("10:00")) + .body("[0].theme.name", is("공포")); + } + + @Test + @DisplayName("관리자가 토큰을 누락했을 경우 401 예외가 발생한다.") + void getAllReservationWithoutToken() { + given().log().all() + .contentType(ContentType.JSON) + .when().get("/admin/reservations") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 예약 삭제를 end-to-end로 확인한다.") + void deleteReservation() { + Long reservationId = saveThemeDateTimeAndReservation("보예"); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().delete("/admin/reservations/{id}", reservationId) + .then().log().all() + .statusCode(204); + + given() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/reservations") + .then() + .statusCode(200) + .body("size()", is(0)); + } + + private Long saveThemeDateTimeAndReservation(String name) { + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + "공포", + "무서운 테마", + "theme-url" + ); + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + "10:00" + ); + + Long themeId = jdbcTemplate.queryForObject( + "SELECT id FROM theme WHERE name = ?", + Long.class, + "공포" + ); + Long dateId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_date WHERE date = ?", + Long.class, + "2026-06-01" + ); + Long timeId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_time WHERE start_at = ?", + Long.class, + "10:00:00" + ); + + jdbcTemplate.update( + "INSERT INTO reservation(name, date_id, time_id, theme_id) VALUES (?, ?, ?, ?)", + name, + dateId, + timeId, + themeId + ); + + return jdbcTemplate.queryForObject( + "SELECT id FROM reservation WHERE name = ?", + Long.class, + name + ); + } +} diff --git a/src/test/java/roomescape/domain/reservation/dto/CreateReservationRequestTest.java b/src/test/java/roomescape/domain/reservation/dto/CreateReservationRequestTest.java deleted file mode 100644 index fdf4749b44..0000000000 --- a/src/test/java/roomescape/domain/reservation/dto/CreateReservationRequestTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package roomescape.domain.reservation.dto; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import org.junit.jupiter.api.Test; -import roomescape.support.exception.RoomescapeException; - -class CreateReservationRequestTest { - - @Test - void 이름이_null이면_예외가_발생한다() { - // given - CreateReservationRequest request = new CreateReservationRequest( - null, - 1L, - 1L, - 1L - ); - - // when & then - assertThatThrownBy(request::validate) - .isInstanceOf(RoomescapeException.class) - .hasMessage("이름은 비어 있을 수 없습니다."); - } - - @Test - void 이름이_공백이면_예외가_발생한다() { - // given - CreateReservationRequest request = new CreateReservationRequest( - " ", - 1L, - 1L, - 1L - ); - - // when & then - assertThatThrownBy(request::validate) - .isInstanceOf(RoomescapeException.class) - .hasMessage("이름은 비어 있을 수 없습니다."); - } - - @Test - void 날짜가_null이면_예외가_발생한다() { - // given - CreateReservationRequest request = new CreateReservationRequest( - "보예", - null, - 1L, - 1L - ); - - // when & then - assertThatThrownBy(request::validate) - .isInstanceOf(RoomescapeException.class) - .hasMessage("날짜는 필수입니다."); - } - - @Test - void 시간_id가_null이면_예외가_발생한다() { - // given - CreateReservationRequest request = new CreateReservationRequest( - "보예", - 1L, - null, - 1L - ); - - // when & then - assertThatThrownBy(request::validate) - .isInstanceOf(RoomescapeException.class) - .hasMessage("시간은 필수입니다."); - } - - @Test - void 테마_id가_null이면_예외가_발생한다() { - // given - CreateReservationRequest request = new CreateReservationRequest( - "보예", - 1L, - 1L, - null - ); - - // when & then - assertThatThrownBy(request::validate) - .isInstanceOf(RoomescapeException.class) - .hasMessage("테마는 필수입니다."); - } -} diff --git a/src/test/java/roomescape/domain/reservationdate/ReservationDateControllerTest.java b/src/test/java/roomescape/domain/reservationdate/ReservationDateControllerTest.java new file mode 100644 index 0000000000..d44bcfe030 --- /dev/null +++ b/src/test/java/roomescape/domain/reservationdate/ReservationDateControllerTest.java @@ -0,0 +1,49 @@ +package roomescape.domain.reservationdate; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.reservationdate.dto.ReservationDateResponse; + +@WebMvcTest(ReservationDateController.class) +class ReservationDateControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReservationDateService reservationDateService; + + @Test + @DisplayName("예약 날짜 조회의 요청과 응답을 확인한다.") + void getAllReservationDates() throws Exception { + // given + ReservationDateResponse response = new ReservationDateResponse( + 1L, + LocalDate.of(2026, 5, 20) + ); + given(reservationDateService.getAllReservationDate()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/reservation-dates") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].reservationDate").value("2026-05-20")); + + verify(reservationDateService).getAllReservationDate(); + } +} diff --git a/src/test/java/roomescape/domain/reservationdate/ReservationDateIntegrationTest.java b/src/test/java/roomescape/domain/reservationdate/ReservationDateIntegrationTest.java new file mode 100644 index 0000000000..aeaee558c9 --- /dev/null +++ b/src/test/java/roomescape/domain/reservationdate/ReservationDateIntegrationTest.java @@ -0,0 +1,50 @@ +package roomescape.domain.reservationdate; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ReservationDateIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("전체 예약 날짜 조회를 end-to-end로 확인한다.") + void getAllReservationDates() { + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + + given().log().all() + .contentType(ContentType.JSON) + .when().get("/reservation-dates") + .then().log().all() + .statusCode(200) + .body("[0].reservationDate", is("2026-06-01")); + } +} diff --git a/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java b/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java index 6df9d3d0b1..7923ebd44f 100644 --- a/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java +++ b/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java @@ -5,22 +5,36 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import roomescape.domain.reservationdate.dto.AdminReservationDateResponse; -import roomescape.domain.reservationdate.dto.CreateReservationDateRequest; -import roomescape.domain.reservationdate.dto.CreateReservationDateResponse; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationdate.admin.dto.AdminReservationDateResponse; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateRequest; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateResponse; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; import roomescape.support.exception.RoomescapeException; import roomescape.support.fake.FakeReservationDateRepository; import roomescape.support.fake.FakeReservationRepository; class ReservationDateServiceTest { + private FakeReservationRepository reservationRepository; + private FakeReservationDateRepository reservationDateRepository; + + @BeforeEach + void setUp() { + reservationRepository = new FakeReservationRepository(); + reservationDateRepository = new FakeReservationDateRepository(); + } + @Test - void 예약_날짜를_생성한다() { + @DisplayName("예약 날짜를 생성한다.") + void createReservationDate() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - FakeReservationDateRepository reservationDateRepository = new FakeReservationDateRepository(); ReservationDateService reservationDateService = new ReservationDateService( reservationRepository, reservationDateRepository @@ -30,21 +44,22 @@ class ReservationDateServiceTest { CreateReservationDateResponse response = reservationDateService.createReservationDate( new CreateReservationDateRequest(LocalDate.of(2026, 5, 4)) ); + ReservationDate reservationDate = reservationDateRepository.findById(response.id()).orElseThrow(); // then assertSoftly(softly -> { - assertThat(response.id()).isEqualTo(1L); + assertThat(response.id()).isEqualTo(reservationDate.getId()); assertThat(response.reservationDate()).isEqualTo(LocalDate.of(2026, 5, 4)); - assertThat(reservationDateRepository.savedReservationDate.getDate()).isEqualTo(LocalDate.of(2026, 5, 4)); + assertThat(reservationDate.getDate()).isEqualTo(LocalDate.of(2026, 5, 4)); }); } @Test - void 예약_날짜_목록을_조회한다() { + @DisplayName("예약 날짜 목록을 조회한다.") + void getReservationDateList() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - FakeReservationDateRepository reservationDateRepository = new FakeReservationDateRepository(); - reservationDateRepository.findAllResult = List.of(ReservationDate.of(1L, LocalDate.of(2026, 5, 4))); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 4))); ReservationDateService reservationDateService = new ReservationDateService( reservationRepository, reservationDateRepository @@ -56,42 +71,51 @@ class ReservationDateServiceTest { // then assertSoftly(softly -> { assertThat(responses).hasSize(1); - assertThat(responses.getFirst().id()).isEqualTo(1L); + assertThat(responses.getFirst().id()).isEqualTo(reservationDate.getId()); assertThat(responses.getFirst().reservationDate()).isEqualTo(LocalDate.of(2026, 5, 4)); }); } @Test - void 이미_예약이_존재하는_날짜는_삭제할_수_없다() { + @DisplayName("이미 예약이 존재하는 날짜는 삭제할 수 없다.") + void throwExceptionWhenDeletingDateInUse() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - reservationRepository.countByReservationDateIdResult = 1; - FakeReservationDateRepository reservationDateRepository = new FakeReservationDateRepository(); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 4))); + reservationRepository.save( + Reservation.createWithoutId( + "보예", + reservationDate, + ReservationTime.of(1L, LocalTime.of(10, 0)), + Theme.of(1L, "공포", "무서운 테마", "theme-url") + ) + ); ReservationDateService reservationDateService = new ReservationDateService( reservationRepository, reservationDateRepository ); // when & then - assertThatThrownBy(() -> reservationDateService.deleteReservationDate(1L)) + assertThatThrownBy(() -> reservationDateService.deleteReservationDate(reservationDate.getId())) .isInstanceOf(RoomescapeException.class) .hasMessage("이미 예약이 존재하는 날짜는 삭제할 수 없습니다."); } @Test - void 예약이_없는_날짜는_삭제한다() { + @DisplayName("예약이 없는 날짜는 삭제한다.") + void deleteDateWhenNoReservationExists() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - FakeReservationDateRepository reservationDateRepository = new FakeReservationDateRepository(); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 4))); ReservationDateService reservationDateService = new ReservationDateService( reservationRepository, reservationDateRepository ); // when - reservationDateService.deleteReservationDate(1L); + reservationDateService.deleteReservationDate(reservationDate.getId()); // then - assertThat(reservationDateRepository.deletedId).isEqualTo(1L); + assertThat(reservationDateRepository.findById(reservationDate.getId())).isEmpty(); } } diff --git a/src/test/java/roomescape/domain/reservationdate/admin/AdminReservationDateControllerTest.java b/src/test/java/roomescape/domain/reservationdate/admin/AdminReservationDateControllerTest.java new file mode 100644 index 0000000000..a03445b4f0 --- /dev/null +++ b/src/test/java/roomescape/domain/reservationdate/admin/AdminReservationDateControllerTest.java @@ -0,0 +1,201 @@ +package roomescape.domain.reservationdate.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDate; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.reservationdate.ReservationDateService; +import roomescape.domain.reservationdate.admin.dto.AdminReservationDateResponse; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateRequest; +import roomescape.domain.reservationdate.admin.dto.CreateReservationDateResponse; +import roomescape.support.auth.AdminRequestValidator; +import roomescape.support.exception.ConflictException; +import roomescape.support.exception.errors.ReservationDateErrors; + +@WebMvcTest(AdminReservationDateController.class) +class AdminReservationDateControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReservationDateService reservationDateService; + + @MockitoBean + private AdminRequestValidator validator; + + @Test + @DisplayName("관리자가 전체 예약 날짜 조회 시 요청과 응답을 확인한다.") + void getAllReservationDatesForAdmin() throws Exception { + // given + AdminReservationDateResponse response = new AdminReservationDateResponse( + 1L, + LocalDate.of(2026, 5, 20) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(reservationDateService.getAllReservationDateForAdmin()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/admin/reservation-dates") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].reservationDate").value("2026-05-20")); + + verify(reservationDateService).getAllReservationDateForAdmin(); + } + + @Test + @DisplayName("관리자 인증에 실패하면 전체 예약 날짜 조회 시 401을 반환한다.") + void getAllReservationDatesForAdminWhenUnauthorized() throws Exception { + // given + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(get("/admin/reservation-dates") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(reservationDateService, never()).getAllReservationDateForAdmin(); + } + + @Test + @DisplayName("관리자가 예약 날짜 생성 시 요청과 응답을 확인한다.") + void createReservationDate() throws Exception { + // given + CreateReservationDateRequest request = new CreateReservationDateRequest( + LocalDate.of(2026, 5, 20) + ); + CreateReservationDateResponse response = new CreateReservationDateResponse( + 1L, + LocalDate.of(2026, 5, 20) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(reservationDateService.createReservationDate(any(CreateReservationDateRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/admin/reservation-dates") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.reservationDate").value("2026-05-20")); + + verify(reservationDateService).createReservationDate(request); + } + + @Test + @DisplayName("관리자 인증에 실패하면 예약 날짜 생성 시 401을 반환한다.") + void createReservationDateWhenUnauthorized() throws Exception { + // given + CreateReservationDateRequest request = new CreateReservationDateRequest( + LocalDate.of(2026, 5, 20) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(post("/admin/reservation-dates") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + + verify(reservationDateService, never()).createReservationDate(any(CreateReservationDateRequest.class)); + } + + @Test + @DisplayName("예약 날짜 생성 시 필수 요청값이 누락되면 예외가 발생한다.") + void createReservationDateWithoutReservationDate() throws Exception { + // given + String request = """ + { + "reservationDate": null + } + """; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(post("/admin/reservation-dates") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token") + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INPUT_VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("예약 날짜는 필수 사항 입니다. 날짜를 선택해주세요.")); + } + + @Test + @DisplayName("관리자가 예약 날짜 삭제 시 요청과 응답을 확인한다.") + void deleteReservationDate() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(delete("/admin/reservation-dates/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isNoContent()); + + verify(reservationDateService).deleteReservationDate(id); + } + + @Test + @DisplayName("관리자 인증에 실패하면 예약 날짜 삭제 시 401을 반환한다.") + void deleteReservationDateWhenUnauthorized() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(delete("/admin/reservation-dates/{id}", id) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(reservationDateService, never()).deleteReservationDate(id); + } + + @Test + @DisplayName("이미 예약이 존재하는 날짜는 삭제할 수 없다.") + void deleteReservationDateWhenDateInUse() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + willThrow(new ConflictException(ReservationDateErrors.RESERVATION_DATE_IN_USE)) + .given(reservationDateService) + .deleteReservationDate(id); + + // when & then + mockMvc.perform(delete("/admin/reservation-dates/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("RESERVATION_DATE_IN_USE")) + .andExpect(jsonPath("$.message").value("이미 예약이 존재하는 날짜는 삭제할 수 없습니다.")); + } +} diff --git a/src/test/java/roomescape/domain/reservationdate/admin/AdminReservationDateIntegrationTest.java b/src/test/java/roomescape/domain/reservationdate/admin/AdminReservationDateIntegrationTest.java new file mode 100644 index 0000000000..8fc15aad6c --- /dev/null +++ b/src/test/java/roomescape/domain/reservationdate/admin/AdminReservationDateIntegrationTest.java @@ -0,0 +1,212 @@ +package roomescape.domain.reservationdate.admin; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AdminReservationDateIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${token}") + private String adminToken; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("관리자의 예약 날짜 전체 조회를 end-to-end로 확인한다.") + void getAllReservationDateForAdmin() { + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/reservation-dates") + .then().log().all() + .statusCode(200) + .body("[0].reservationDate", is("2026-06-01")); + } + + @Test + @DisplayName("관리자가 토큰을 누락했을 경우 401 예외가 발생한다.") + void getAllReservationDateForAdminWithoutToken() { + given().log().all() + .contentType(ContentType.JSON) + .when().get("/admin/reservation-dates") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 예약 날짜 생성을 end-to-end로 확인한다.") + void createReservationDate() { + String request = """ + { + "reservationDate": "2026-06-01" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .body(request) + .when().post("/admin/reservation-dates") + .then().log().all() + .statusCode(201) + .body("reservationDate", is("2026-06-01")); + + given() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/reservation-dates") + .then() + .statusCode(200) + .body("reservationDate", hasItem("2026-06-01")); + } + + @Test + @DisplayName("관리자 예약 날짜 생성 시 날짜 필드가 누락되었을 경우 400 에러가 발생한다.") + void createReservationDateWithoutDate() { + String request = """ + { + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .body(request) + .when().post("/admin/reservation-dates") + .then().log().all() + .statusCode(400) + .body("code", is("INPUT_VALIDATION_ERROR")) + .body("message", is("예약 날짜는 필수 사항 입니다. 날짜를 선택해주세요.")); + } + + @Test + @DisplayName("관리자 예약 날짜 생성 시 관리자 인증 토큰이 누락되었을 경우 401 에러가 발생한다.") + void createReservationDateWithoutToken() { + String request = """ + { + "reservationDate": "2026-06-01" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/admin/reservation-dates") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 예약 날짜 삭제를 end-to-end로 확인한다.") + void deleteReservationDate() { + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + + Long dateId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_date WHERE date = ?", + Long.class, + "2026-06-01" + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().delete("/admin/reservation-dates/{id}", dateId) + .then().log().all() + .statusCode(204); + + given() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/reservation-dates") + .then() + .statusCode(200) + .body("reservationDate", not(hasItem("2026-06-01"))); + } + + @Test + @DisplayName("이미 예약이 존재하는 날짜는 삭제할 수 없다.") + void deleteReservationDateWhenDateInUse() { + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + "10:00" + ); + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + "공포", + "무서운 테마", + "theme-url" + ); + + Long dateId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_date WHERE date = ?", + Long.class, + "2026-06-01" + ); + Long timeId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_time WHERE start_at = ?", + Long.class, + "10:00:00" + ); + Long themeId = jdbcTemplate.queryForObject( + "SELECT id FROM theme WHERE name = ?", + Long.class, + "공포" + ); + + jdbcTemplate.update( + "INSERT INTO reservation(name, date_id, time_id, theme_id) VALUES (?, ?, ?, ?)", + "보예", + dateId, + timeId, + themeId + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().delete("/admin/reservation-dates/{id}", dateId) + .then().log().all() + .statusCode(409) + .body("code", is("RESERVATION_DATE_IN_USE")) + .body("message", is("이미 예약이 존재하는 날짜는 삭제할 수 없습니다.")); + } +} diff --git a/src/test/java/roomescape/domain/reservationtime/ReservationTimeControllerTest.java b/src/test/java/roomescape/domain/reservationtime/ReservationTimeControllerTest.java new file mode 100644 index 0000000000..428212f4a2 --- /dev/null +++ b/src/test/java/roomescape/domain/reservationtime/ReservationTimeControllerTest.java @@ -0,0 +1,88 @@ +package roomescape.domain.reservationtime; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalTime; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.reservationtime.dto.ReservationTimeAvailabilityResponse; +import roomescape.support.exception.NotFoundException; +import roomescape.support.exception.errors.ThemeErrors; + +@WebMvcTest(ReservationTimeController.class) +class ReservationTimeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReservationTimeService reservationTimeService; + + @Test + @DisplayName("예약 가능 시간 조회의 요청과 응답을 확인한다.") + void getReservationTimeAvailability() throws Exception { + // given + Long themeId = 1L; + Long dateId = 2L; + ReservationTimeAvailabilityResponse response = new ReservationTimeAvailabilityResponse( + 3L, + LocalTime.of(10, 10), + true + ); + given(reservationTimeService.getReservationTimeAvailability(themeId, dateId)) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/reservation-times/availability") + .contentType(MediaType.APPLICATION_JSON) + .param("themeId", String.valueOf(themeId)) + .param("dateId", String.valueOf(dateId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].timeId").value(3)) + .andExpect(jsonPath("$[0].startAt").value("10:10")) + .andExpect(jsonPath("$[0].available").value(true)); + + verify(reservationTimeService).getReservationTimeAvailability(themeId, dateId); + } + + @Test + @DisplayName("예약 가능 시간 조회 시 themeId가 누락되면 예외가 발생한다.") + void getReservationTimeAvailabilityWithoutThemeId() throws Exception { + // given & when & then + mockMvc.perform(get("/reservation-times/availability") + .contentType(MediaType.APPLICATION_JSON) + .param("dateId", "2")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("REQUIRED_PARAMETER_MISSING")) + .andExpect(jsonPath("$.message").value("필수 요청 파라미터가 누락되었습니다.")); + } + + @Test + @DisplayName("존재하지 않는 테마로 예약 가능 시간 조회 시 예외가 발생한다.") + void getReservationTimeAvailabilityWhenThemeNotFound() throws Exception { + // given + Long themeId = 999L; + Long dateId = 2L; + given(reservationTimeService.getReservationTimeAvailability(themeId, dateId)) + .willThrow(new NotFoundException(ThemeErrors.THEME_NOT_EXIST)); + + // when & then + mockMvc.perform(get("/reservation-times/availability") + .contentType(MediaType.APPLICATION_JSON) + .param("themeId", String.valueOf(themeId)) + .param("dateId", String.valueOf(dateId))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("THEME_NOT_EXIST")) + .andExpect(jsonPath("$.message").value("존재하지 않는 테마 입니다.")); + } +} diff --git a/src/test/java/roomescape/domain/reservationtime/ReservationTimeIntegrationTest.java b/src/test/java/roomescape/domain/reservationtime/ReservationTimeIntegrationTest.java new file mode 100644 index 0000000000..30b7d94b59 --- /dev/null +++ b/src/test/java/roomescape/domain/reservationtime/ReservationTimeIntegrationTest.java @@ -0,0 +1,133 @@ +package roomescape.domain.reservationtime; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ReservationTimeIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("예약 가능 시간 조회를 end-to-end로 확인한다.") + void getReservationTimeAvailability() { + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + "공포", "무서운 테마", "theme-url" + ); + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + "10:00" + ); + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + "11:00" + ); + Long themeId = jdbcTemplate.queryForObject( + "SELECT id FROM theme WHERE name = ?", + Long.class, + "공포" + ); + Long dateId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_date WHERE date = ?", + Long.class, + "2026-06-01" + ); + Long firstTimeId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_time WHERE start_at = ?", + Long.class, + "10:00:00" + ); + Long secondTimeId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_time WHERE start_at = ?", + Long.class, + "11:00:00" + ); + jdbcTemplate.update( + "INSERT INTO reservation(name, date_id, time_id, theme_id) VALUES (?, ?, ?, ?)", + "보예", + dateId, + firstTimeId, + themeId + ); + + given().log().all() + .contentType(ContentType.JSON) + .param("themeId", themeId) + .param("dateId", dateId) + .when().get("/reservation-times/availability") + .then().log().all() + .statusCode(200) + .body("[0].timeId", is(firstTimeId.intValue())) + .body("[0].startAt", is("10:00")) + .body("[0].available", is(false)) + .body("[1].timeId", is(secondTimeId.intValue())) + .body("[1].startAt", is("11:00")) + .body("[1].available", is(true)); + } + + @Test + @DisplayName("예약 가능 시간 조회 시 themeId 파라미터가 누락되었을 경우 400 에러가 발생한다.") + void getReservationTimeAvailabilityWithoutThemeId() { + given().log().all() + .contentType(ContentType.JSON) + .param("dateId", 1L) + .when().get("/reservation-times/availability") + .then().log().all() + .statusCode(400) + .body("code", is("REQUIRED_PARAMETER_MISSING")) + .body("message", is("필수 요청 파라미터가 누락되었습니다.")); + } + + @Test + @DisplayName("예약 가능 시간 조회 시 존재하지 않는 테마일 경우 404 에러가 발생한다.") + void getReservationTimeAvailabilityWhenThemeNotFound() { + jdbcTemplate.update( + "INSERT INTO reservation_date(date) VALUES (?)", + "2026-06-01" + ); + Long dateId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_date WHERE date = ?", + Long.class, + "2026-06-01" + ); + + given().log().all() + .contentType(ContentType.JSON) + .param("themeId", 999L) + .param("dateId", dateId) + .when().get("/reservation-times/availability") + .then().log().all() + .statusCode(404) + .body("code", is("THEME_NOT_EXIST")) + .body("message", is("존재하지 않는 테마 입니다.")); + } +} diff --git a/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java b/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java index fddee053b2..4c83f51fa6 100644 --- a/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java +++ b/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java @@ -2,95 +2,198 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.BDDAssertions.tuple; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import roomescape.domain.reservationtime.dto.CreateTimeRequest; -import roomescape.domain.reservationtime.dto.CreateTimeResponse; -import roomescape.domain.reservationtime.dto.ReservationTimeResponse; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationdate.ReservationDate; +import roomescape.domain.reservationtime.admin.dto.CreateTimeRequest; +import roomescape.domain.reservationtime.admin.dto.CreateTimeResponse; +import roomescape.domain.reservationtime.dto.ReservationTimeAvailabilityResponse; +import roomescape.domain.reservationtime.admin.dto.ReservationTimeResponse; +import roomescape.domain.theme.Theme; +import roomescape.support.exception.NotFoundException; import roomescape.support.exception.RoomescapeException; +import roomescape.support.fake.FakeReservationDateRepository; import roomescape.support.fake.FakeReservationRepository; import roomescape.support.fake.FakeReservationTimeRepository; +import roomescape.support.fake.FakeThemeRepository; class ReservationTimeServiceTest { + private FakeReservationRepository reservationRepository; + private FakeReservationTimeRepository reservationTimeRepository; + private FakeThemeRepository themeRepository; + private FakeReservationDateRepository reservationDateRepository; + + @BeforeEach + void setUp() { + reservationRepository = new FakeReservationRepository(); + reservationTimeRepository = new FakeReservationTimeRepository(); + themeRepository = new FakeThemeRepository(); + reservationDateRepository = new FakeReservationDateRepository(); + } + @Test - void 예약_시간을_생성한다() { + @DisplayName("예약 시간을 생성한다.") + void createReservationTime() { // given - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - ReservationTimeService reservationTimeService = new ReservationTimeService( - reservationTimeRepository, - reservationRepository - ); + ReservationTimeService reservationTimeService = createReservationTimeService(); // when CreateTimeResponse response = reservationTimeService.createReservationTime( new CreateTimeRequest(LocalTime.of(10, 0)) ); + ReservationTime savedReservationTime = reservationTimeRepository.findById(response.id()).orElseThrow(); // then assertSoftly(softly -> { - assertThat(response.id()).isEqualTo(1L); + assertThat(response.id()).isEqualTo(savedReservationTime.getId()); assertThat(response.startAt()).isEqualTo(LocalTime.of(10, 0)); - assertThat(reservationTimeRepository.savedReservationTime.getStartAt()).isEqualTo(LocalTime.of(10, 0)); + assertThat(savedReservationTime.getStartAt()).isEqualTo(LocalTime.of(10, 0)); }); } @Test - void 예약_시간_목록을_조회한다() { + @DisplayName("예약 시간 목록을 조회한다.") + void getReservationTimeList() { // given - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - reservationTimeRepository.findAllResult = List.of(ReservationTime.of(1L, LocalTime.of(10, 0))); - ReservationTimeService reservationTimeService = new ReservationTimeService( - reservationTimeRepository, - new FakeReservationRepository() - ); + reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(10, 0))); + reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(11, 0))); + ReservationTimeService reservationTimeService = createReservationTimeService(); // when List responses = reservationTimeService.getAllReservationTime(); // then assertSoftly(softly -> { - assertThat(responses).hasSize(1); - assertThat(responses.getFirst().id()).isEqualTo(1L); - assertThat(responses.getFirst().startAt()).isEqualTo(LocalTime.of(10, 0)); + assertThat(responses).hasSize(2); + assertThat(responses) + .extracting(ReservationTimeResponse::id, ReservationTimeResponse::startAt) + .containsExactly( + tuple(1L, LocalTime.of(10, 0)), + tuple(2L, LocalTime.of(11, 0)) + ); }); } @Test - void 이미_예약이_존재하는_시간은_삭제할_수_없다() { + @DisplayName("이미 예약이 존재하는 시간은 삭제할 수 없다.") + void throwExceptionWhenDeletingTimeInUse() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - reservationRepository.countByTimeIdResult = 1; - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - ReservationTimeService reservationTimeService = new ReservationTimeService( - reservationTimeRepository, - reservationRepository + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) ); + reservationRepository.save( + Reservation.createWithoutId( + "보예", + ReservationDate.of(1L, LocalDate.of(2026, 5, 12)), + reservationTime, + Theme.of(1L, "공포", "무서운 테마", "theme-url") + ) + ); + ReservationTimeService reservationTimeService = createReservationTimeService(); // when & then - assertThatThrownBy(() -> reservationTimeService.deleteReservationTime(1L)) + assertThatThrownBy(() -> reservationTimeService.deleteReservationTime(reservationTime.getId())) .isInstanceOf(RoomescapeException.class) .hasMessage("이미 예약이 존재하는 시간대는 삭제할 수 없습니다."); } @Test - void 예약이_없는_시간은_삭제한다() { + @DisplayName("예약이 없는 시간은 삭제한다.") + void deleteTimeWhenNoReservationExists() { // given - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); - ReservationTimeService reservationTimeService = new ReservationTimeService( - reservationTimeRepository, - reservationRepository + ReservationTime reservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationTimeService reservationTimeService = createReservationTimeService(); + + // when + reservationTimeService.deleteReservationTime(reservationTime.getId()); + + // then + assertThat(reservationTimeRepository.findById(reservationTime.getId())).isEmpty(); + } + + @Test + @DisplayName("예약 가능 시간을 조회한다.") + void getReservationTimeAvailability() { + // given + ReservationTime firstReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(10, 0)) + ); + ReservationTime secondReservationTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(11, 0)) ); + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 16)) + ); + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + reservationRepository.save( + Reservation.createWithoutId("보예", reservationDate, firstReservationTime, theme) + ); + ReservationTimeService reservationTimeService = createReservationTimeService(); // when - reservationTimeService.deleteReservationTime(1L); + List responses = reservationTimeService.getReservationTimeAvailability( + theme.getId(), + reservationDate.getId() + ); // then - assertThat(reservationTimeRepository.deletedId).isEqualTo(1L); + assertThat(responses) + .extracting( + ReservationTimeAvailabilityResponse::timeId, + ReservationTimeAvailabilityResponse::startAt, + ReservationTimeAvailabilityResponse::available + ) + .containsExactly( + tuple(firstReservationTime.getId(), LocalTime.of(10, 0), false), + tuple(secondReservationTime.getId(), LocalTime.of(11, 0), true) + ); + } + + @Test + @DisplayName("존재하지 않는 테마로 예약 가능 시간을 조회할 수 없다.") + void throwExceptionWhenThemeDoesNotExistForAvailability() { + // given + ReservationDate reservationDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 5, 16)) + ); + ReservationTimeService reservationTimeService = createReservationTimeService(); + + // when & then + assertThatThrownBy(() -> reservationTimeService.getReservationTimeAvailability(1L, reservationDate.getId())) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 테마 입니다."); + } + + @Test + @DisplayName("존재하지 않는 날짜로 예약 가능 시간을 조회할 수 없다.") + void throwExceptionWhenDateDoesNotExistForAvailability() { + // given + Theme theme = themeRepository.save(Theme.createWithoutId("공포", "무서운 테마", "theme-url")); + ReservationTimeService reservationTimeService = createReservationTimeService(); + + // when & then + assertThatThrownBy(() -> reservationTimeService.getReservationTimeAvailability(theme.getId(), 1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 날짜 입니다."); + } + + private ReservationTimeService createReservationTimeService() { + return new ReservationTimeService( + reservationTimeRepository, + reservationRepository, + themeRepository, + reservationDateRepository + ); } } diff --git a/src/test/java/roomescape/domain/reservationtime/ReservationTimeTest.java b/src/test/java/roomescape/domain/reservationtime/ReservationTimeTest.java index 5254804eaa..5f54d82a20 100644 --- a/src/test/java/roomescape/domain/reservationtime/ReservationTimeTest.java +++ b/src/test/java/roomescape/domain/reservationtime/ReservationTimeTest.java @@ -4,13 +4,15 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import roomescape.support.exception.RoomescapeException; class ReservationTimeTest { @Test - void id가_없는_예약_시간을_생성한다() { + @DisplayName("id가 없는 예약 시간을 생성한다.") + void createReservationTimeWithoutId() { // given LocalTime startAt = LocalTime.of(10, 0); @@ -25,7 +27,8 @@ class ReservationTimeTest { } @Test - void 예약_시간이_null이면_예외가_발생한다() { + @DisplayName("예약 시간이 null이면 예외가 발생한다.") + void throwExceptionWhenReservationTimeIsNull() { // given LocalTime startAt = null; diff --git a/src/test/java/roomescape/domain/reservationtime/admin/AdminReservationTimeControllerTest.java b/src/test/java/roomescape/domain/reservationtime/admin/AdminReservationTimeControllerTest.java new file mode 100644 index 0000000000..67e17c4a52 --- /dev/null +++ b/src/test/java/roomescape/domain/reservationtime/admin/AdminReservationTimeControllerTest.java @@ -0,0 +1,201 @@ +package roomescape.domain.reservationtime.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalTime; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.reservationtime.ReservationTimeService; +import roomescape.domain.reservationtime.admin.dto.CreateTimeRequest; +import roomescape.domain.reservationtime.admin.dto.CreateTimeResponse; +import roomescape.domain.reservationtime.admin.dto.ReservationTimeResponse; +import roomescape.support.auth.AdminRequestValidator; +import roomescape.support.exception.ConflictException; +import roomescape.support.exception.errors.ReservationTimeErrors; + +@WebMvcTest(AdminReservationTimeController.class) +class AdminReservationTimeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReservationTimeService reservationTimeService; + + @MockitoBean + private AdminRequestValidator validator; + + @Test + @DisplayName("관리자가 전체 예약 시간 조회 시 요청과 응답을 확인한다.") + void getAllReservationTime() throws Exception { + // given + ReservationTimeResponse response = new ReservationTimeResponse( + 1L, + LocalTime.of(10, 10) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(reservationTimeService.getAllReservationTime()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/admin/times") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].startAt").value("10:10")); + + verify(reservationTimeService).getAllReservationTime(); + } + + @Test + @DisplayName("관리자 인증에 실패하면 전체 예약 시간 조회 시 401을 반환한다.") + void getAllReservationTimeWhenUnauthorized() throws Exception { + // given + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(get("/admin/times") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(reservationTimeService, never()).getAllReservationTime(); + } + + @Test + @DisplayName("관리자가 예약 시간 생성 시 요청과 응답을 확인한다.") + void createReservationTime() throws Exception { + // given + CreateTimeRequest request = new CreateTimeRequest( + LocalTime.of(10, 10) + ); + CreateTimeResponse response = new CreateTimeResponse( + 1L, + LocalTime.of(10, 10) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(reservationTimeService.createReservationTime(any(CreateTimeRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/admin/times") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.startAt").value("10:10")); + + verify(reservationTimeService).createReservationTime(request); + } + + @Test + @DisplayName("관리자 인증에 실패하면 예약 시간 생성 시 401을 반환한다.") + void createReservationTimeWhenUnauthorized() throws Exception { + // given + CreateTimeRequest request = new CreateTimeRequest( + LocalTime.of(10, 10) + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(post("/admin/times") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + + verify(reservationTimeService, never()).createReservationTime(any(CreateTimeRequest.class)); + } + + @Test + @DisplayName("예약 시간 생성 시 필수 요청값이 누락되면 예외가 발생한다.") + void createReservationTimeWithoutStartAt() throws Exception { + // given + String request = """ + { + "startAt": null + } + """; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(post("/admin/times") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token") + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INPUT_VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("시간은 필수 사항 입니다. 시간을 선택해주세요.")); + } + + @Test + @DisplayName("관리자가 예약 시간 삭제 시 요청과 응답을 확인한다.") + void deleteReservationTime() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(delete("/admin/times/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isOk()); + + verify(reservationTimeService).deleteReservationTime(id); + } + + @Test + @DisplayName("관리자 인증에 실패하면 예약 시간 삭제 시 401을 반환한다.") + void deleteReservationTimeWhenUnauthorized() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(delete("/admin/times/{id}", id) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(reservationTimeService, never()).deleteReservationTime(id); + } + + @Test + @DisplayName("이미 예약이 존재하는 시간대는 삭제할 수 없다.") + void deleteReservationTimeWhenTimeInUse() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + willThrow(new ConflictException(ReservationTimeErrors.RESERVATION_TIME_IN_USE)) + .given(reservationTimeService) + .deleteReservationTime(id); + + // when & then + mockMvc.perform(delete("/admin/times/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("RESERVATION_TIME_IN_USE")) + .andExpect(jsonPath("$.message").value("이미 예약이 존재하는 시간대는 삭제할 수 없습니다.")); + } +} diff --git a/src/test/java/roomescape/domain/reservationtime/admin/AdminReservationTimeIntegrationTest.java b/src/test/java/roomescape/domain/reservationtime/admin/AdminReservationTimeIntegrationTest.java new file mode 100644 index 0000000000..c67585d33f --- /dev/null +++ b/src/test/java/roomescape/domain/reservationtime/admin/AdminReservationTimeIntegrationTest.java @@ -0,0 +1,160 @@ +package roomescape.domain.reservationtime.admin; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AdminReservationTimeIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${token}") + private String adminToken; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("관리자의 예약 시간 전체 조회를 end-to-end로 확인한다.") + void getAllReservationTime() { + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + "10:10" + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/times") + .then().log().all() + .statusCode(200) + .body("[0].startAt", is("10:10")); + } + + @Test + @DisplayName("관리자가 토큰을 누락했을 경우 401 예외가 발생한다.") + void getAllReservationTimeWithoutToken() { + given().log().all() + .contentType(ContentType.JSON) + .when().get("/admin/times") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 예약 시간 생성을 end-to-end로 확인한다.") + void createReservationTime() { + String request = """ + { + "startAt": "10:10" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .body(request) + .when().post("/admin/times") + .then().log().all() + .statusCode(200) + .body("startAt", is("10:10")); + + given() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/times") + .then() + .statusCode(200) + .body("startAt", hasItem("10:10")); + } + + @Test + @DisplayName("관리자 예약 시간 생성 시 시작 시간이 누락되었을 경우 400 에러가 발생한다.") + void createReservationTimeWithoutStartAt() { + String request = """ + { + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .body(request) + .when().post("/admin/times") + .then().log().all() + .statusCode(400) + .body("code", is("INPUT_VALIDATION_ERROR")) + .body("message", is("시간은 필수 사항 입니다. 시간을 선택해주세요.")); + } + + @Test + @DisplayName("관리자 예약 시간 생성 시 관리자 인증 토큰이 누락되었을 경우 401 에러가 발생한다.") + void createReservationTimeWithoutToken() { + String request = """ + { + "startAt": "10:10" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/admin/times") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 예약 시간 삭제를 end-to-end로 확인한다.") + void deleteReservationTime() { + jdbcTemplate.update( + "INSERT INTO reservation_time(start_at) VALUES (?)", + "10:10" + ); + + Long timeId = jdbcTemplate.queryForObject( + "SELECT id FROM reservation_time WHERE start_at = ?", + Long.class, + "10:10:00" + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().delete("/admin/times/{id}", timeId) + .then().log().all() + .statusCode(200); + + given() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/times") + .then() + .statusCode(200) + .body("startAt", not(hasItem("10:10"))); + } +} diff --git a/src/test/java/roomescape/domain/theme/ThemeControllerTest.java b/src/test/java/roomescape/domain/theme/ThemeControllerTest.java new file mode 100644 index 0000000000..3c4d5f77f7 --- /dev/null +++ b/src/test/java/roomescape/domain/theme/ThemeControllerTest.java @@ -0,0 +1,76 @@ +package roomescape.domain.theme; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.theme.dto.ThemeRankResponse; +import roomescape.domain.theme.dto.ThemeResponse; + +@WebMvcTest(ThemeController.class) +class ThemeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ThemeService themeService; + + @Test + @DisplayName("전체 테마 조회의 요청과 응답을 확인한다.") + void getAllTheme() throws Exception { + // given + ThemeResponse response = new ThemeResponse( + 1L, + "공포", + "무서운 테마", + "theme-url" + ); + given(themeService.getAllTheme()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/themes") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].name").value("공포")) + .andExpect(jsonPath("$[0].content").value("무서운 테마")) + .andExpect(jsonPath("$[0].url").value("theme-url")); + + verify(themeService).getAllTheme(); + } + + @Test + @DisplayName("테마 랭킹 조회의 요청과 응답을 확인한다.") + void getThemeRank() throws Exception { + // given + ThemeRankResponse response = new ThemeRankResponse( + 1L, + "공포", + "theme-url" + ); + given(themeService.getThemeRank()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/themes/rank") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].themeName").value("공포")) + .andExpect(jsonPath("$[0].url").value("theme-url")); + + verify(themeService).getThemeRank(); + } +} diff --git a/src/test/java/roomescape/domain/theme/ThemeIntegrationTest.java b/src/test/java/roomescape/domain/theme/ThemeIntegrationTest.java new file mode 100644 index 0000000000..c1490596d9 --- /dev/null +++ b/src/test/java/roomescape/domain/theme/ThemeIntegrationTest.java @@ -0,0 +1,52 @@ +package roomescape.domain.theme; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ThemeIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("전체 테마 조회를 end-to-end로 확인한다.") + void getAllTheme() { + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + "공포", "무서운 테마", "theme-url" + ); + + given().log().all() + .contentType(ContentType.JSON) + .when().get("/themes") + .then().log().all() + .statusCode(200) + .body("[0].name", is("공포")) + .body("[0].content", is("무서운 테마")) + .body("[0].url", is("theme-url")); + } +} diff --git a/src/test/java/roomescape/domain/theme/ThemeServiceTest.java b/src/test/java/roomescape/domain/theme/ThemeServiceTest.java index f044af3ace..fd7cc9b28a 100644 --- a/src/test/java/roomescape/domain/theme/ThemeServiceTest.java +++ b/src/test/java/roomescape/domain/theme/ThemeServiceTest.java @@ -4,24 +4,32 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import roomescape.domain.theme.dto.AdminThemeResponse; -import roomescape.domain.theme.dto.CreateThemeRequest; -import roomescape.domain.theme.dto.CreateThemeResponse; +import roomescape.domain.theme.admin.dto.AdminThemeResponse; +import roomescape.domain.theme.admin.dto.CreateThemeRequest; +import roomescape.domain.theme.admin.dto.CreateThemeResponse; import roomescape.domain.theme.dto.ThemeResponse; import roomescape.support.fake.FakeReservationRepository; import roomescape.support.fake.FakeThemeRepository; class ThemeServiceTest { + private FakeReservationRepository reservationRepository; + private FakeThemeRepository themeRepository; + + @BeforeEach + void setUp() { + reservationRepository = new FakeReservationRepository(); + themeRepository = new FakeThemeRepository(); + } + @Test - void 관리자용_테마_목록을_조회한다() { + @DisplayName("관리자용 테마 목록을 조회한다.") + void getThemeListForAdmin() { // given - FakeThemeRepository themeRepository = new FakeThemeRepository(); - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - themeRepository.findAllResult = List.of( - Theme.of(1L, "미스터리", "이게 뭘까? 바로바로 추리 테마", "theme/mystery") - ); + themeRepository.save(Theme.createWithoutId("미스터리", "보예의 미스터리", "theme-url")); ThemeService themeService = new ThemeService(themeRepository, reservationRepository); // when @@ -32,19 +40,16 @@ class ThemeServiceTest { assertThat(responses).hasSize(1); assertThat(responses.getFirst().id()).isEqualTo(1L); assertThat(responses.getFirst().name()).isEqualTo("미스터리"); - assertThat(responses.getFirst().content()).isEqualTo("이게 뭘까? 바로바로 추리 테마"); - assertThat(responses.getFirst().url()).isEqualTo("theme/mystery"); + assertThat(responses.getFirst().content()).isEqualTo("보예의 미스터리"); + assertThat(responses.getFirst().url()).isEqualTo("theme-url"); }); } @Test - void 사용자용_테마_목록을_조회한다() { + @DisplayName("사용자용 테마 목록을 조회한다.") + void getThemeListForUser() { // given - FakeThemeRepository themeRepository = new FakeThemeRepository(); - FakeReservationRepository reservationRepository = new FakeReservationRepository(); - themeRepository.findAllResult = List.of( - Theme.of(1L, "미스터리", "이게 뭘까? 바로바로 추리 테마", "theme/mystery") - ); + themeRepository.save(Theme.createWithoutId("미스터리", "보예의 미스터리", "theme-url")); ThemeService themeService = new ThemeService(themeRepository, reservationRepository); // when @@ -55,46 +60,48 @@ class ThemeServiceTest { assertThat(responses).hasSize(1); assertThat(responses.getFirst().id()).isEqualTo(1L); assertThat(responses.getFirst().name()).isEqualTo("미스터리"); - assertThat(responses.getFirst().content()).isEqualTo("이게 뭘까? 바로바로 추리 테마"); - assertThat(responses.getFirst().url()).isEqualTo("theme/mystery"); + assertThat(responses.getFirst().content()).isEqualTo("보예의 미스터리"); + assertThat(responses.getFirst().url()).isEqualTo("theme-url"); }); } @Test - void 테마를_생성한다() { + @DisplayName("테마를 생성한다.") + void createTheme() { // given - FakeThemeRepository themeRepository = new FakeThemeRepository(); - FakeReservationRepository reservationRepository = new FakeReservationRepository(); ThemeService themeService = new ThemeService(themeRepository, reservationRepository); // when CreateThemeResponse response = themeService.createTheme( - new CreateThemeRequest("미스터리", "이게 뭘까? 바로바로 추리 테마", "theme/mystery") + new CreateThemeRequest("미스터리", "보예의 미스터리", "theme-url") ); + Theme theme = themeRepository.findById(response.id()).orElseThrow(); // then assertSoftly(softly -> { - assertThat(response.id()).isEqualTo(1L); + assertThat(response.id()).isEqualTo(theme.getId()); assertThat(response.name()).isEqualTo("미스터리"); - assertThat(response.content()).isEqualTo("이게 뭘까? 바로바로 추리 테마"); - assertThat(response.url()).isEqualTo("theme/mystery"); - assertThat(themeRepository.savedTheme.getName()).isEqualTo("미스터리"); - assertThat(themeRepository.savedTheme.getContent()).isEqualTo("이게 뭘까? 바로바로 추리 테마"); - assertThat(themeRepository.savedTheme.getUrl()).isEqualTo("theme/mystery"); + assertThat(response.content()).isEqualTo("보예의 미스터리"); + assertThat(response.url()).isEqualTo("theme-url"); + assertThat(theme.getName()).isEqualTo("미스터리"); + assertThat(theme.getContent()).isEqualTo("보예의 미스터리"); + assertThat(theme.getUrl()).isEqualTo("theme-url"); }); } @Test - void 테마를_삭제한다() { + @DisplayName("테마를 삭제한다.") + void deleteTheme() { // given - FakeThemeRepository themeRepository = new FakeThemeRepository(); - FakeReservationRepository reservationRepository = new FakeReservationRepository(); + Theme theme = themeRepository.save( + Theme.createWithoutId("공포", "무섭다", "theme-url") + ); ThemeService themeService = new ThemeService(themeRepository, reservationRepository); // when - themeService.deleteTheme(1L); + themeService.deleteTheme(theme.getId()); // then - assertThat(themeRepository.deletedId).isEqualTo(1L); + assertThat(themeRepository.findById(theme.getId())).isEmpty(); } } diff --git a/src/test/java/roomescape/domain/theme/ThemeTest.java b/src/test/java/roomescape/domain/theme/ThemeTest.java new file mode 100644 index 0000000000..d7ad7fe6fa --- /dev/null +++ b/src/test/java/roomescape/domain/theme/ThemeTest.java @@ -0,0 +1,44 @@ +package roomescape.domain.theme; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import roomescape.support.exception.RoomescapeException; + +class ThemeTest { + + @Test + @DisplayName("id가 없는 테마를 생성한다.") + void createThemeWithoutId() { + // given + String name = "미스터리"; + String content = "보예의 미스터리"; + String url = "theme-url"; + + // when + Theme theme = Theme.createWithoutId(name, content, url); + + // then + assertSoftly(softly -> { + assertThat(theme.getId()).isNull(); + assertThat(theme.getName()).isEqualTo(name); + assertThat(theme.getContent()).isEqualTo(content); + assertThat(theme.getUrl()).isEqualTo(url); + }); + } + + @Test + @DisplayName("이름이 10자를 초과하면 예외가 발생한다.") + void throwExceptionWhenNameExceedsTenCharacters() { + // given + String name = "공포공포공포공포공포공"; + + // when & then + assertThatThrownBy(() -> Theme.createWithoutId(name, "보예의 미스터리", "theme-url")) + .isInstanceOf(RoomescapeException.class) + .hasMessage("테마 이름은 10자 이하여야 합니다."); + } +} diff --git a/src/test/java/roomescape/domain/theme/admin/AdminThemeControllerTest.java b/src/test/java/roomescape/domain/theme/admin/AdminThemeControllerTest.java new file mode 100644 index 0000000000..185617fe2f --- /dev/null +++ b/src/test/java/roomescape/domain/theme/admin/AdminThemeControllerTest.java @@ -0,0 +1,214 @@ +package roomescape.domain.theme.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import roomescape.domain.theme.ThemeService; +import roomescape.domain.theme.admin.dto.AdminThemeResponse; +import roomescape.domain.theme.admin.dto.CreateThemeRequest; +import roomescape.domain.theme.admin.dto.CreateThemeResponse; +import roomescape.support.auth.AdminRequestValidator; +import roomescape.support.exception.ConflictException; +import roomescape.support.exception.errors.ThemeErrors; + +@WebMvcTest(AdminThemeController.class) +class AdminThemeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ThemeService themeService; + + @MockitoBean + private AdminRequestValidator validator; + + @Test + @DisplayName("관리자가 전체 테마 조회 시 요청과 응답을 확인한다.") + void getAllThemeForAdmin() throws Exception { + // given + AdminThemeResponse response = new AdminThemeResponse( + 1L, + "공포", + "무서운 테마", + "theme-url" + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(themeService.getAllThemeForAdmin()) + .willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/admin/themes") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].name").value("공포")) + .andExpect(jsonPath("$[0].content").value("무서운 테마")) + .andExpect(jsonPath("$[0].url").value("theme-url")); + + verify(themeService).getAllThemeForAdmin(); + } + + @Test + @DisplayName("관리자 인증에 실패하면 전체 테마 조회 시 401을 반환한다.") + void getAllThemeForAdminWhenUnauthorized() throws Exception { + // given + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(get("/admin/themes") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(themeService, never()).getAllThemeForAdmin(); + } + + @Test + @DisplayName("관리자가 테마 생성 시 요청과 응답을 확인한다.") + void createTheme() throws Exception { + // given + CreateThemeRequest request = new CreateThemeRequest( + "공포", + "무서운 테마", + "theme-url" + ); + CreateThemeResponse response = new CreateThemeResponse( + 1L, + "공포", + "무서운 테마", + "theme-url" + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + given(themeService.createTheme(any(CreateThemeRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/admin/themes") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.name").value("공포")) + .andExpect(jsonPath("$.content").value("무서운 테마")) + .andExpect(jsonPath("$.url").value("theme-url")); + + verify(themeService).createTheme(request); + } + + @Test + @DisplayName("관리자 인증에 실패하면 테마 생성 시 401을 반환한다.") + void createThemeWhenUnauthorized() throws Exception { + // given + CreateThemeRequest request = new CreateThemeRequest( + "공포", + "무서운 테마", + "theme-url" + ); + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(post("/admin/themes") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "wrong-token") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + + verify(themeService, never()).createTheme(any(CreateThemeRequest.class)); + } + + @Test + @DisplayName("테마 생성 시 필수 요청값이 누락되면 예외가 발생한다.") + void createThemeWithoutName() throws Exception { + // given + String request = """ + { + "name": "", + "content": "무서운 테마", + "url": "theme-url" + } + """; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(post("/admin/themes") + .contentType(MediaType.APPLICATION_JSON) + .header("X-ADMIN-TOKEN", "token") + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INPUT_VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value("테마 이름은 비어있을 수 없습니다.")); + } + + @Test + @DisplayName("관리자가 테마 삭제 시 요청과 응답을 확인한다.") + void deleteTheme() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + + // when & then + mockMvc.perform(delete("/admin/themes/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isNoContent()); + + verify(themeService).deleteTheme(id); + } + + @Test + @DisplayName("관리자 인증에 실패하면 테마 삭제 시 401을 반환한다.") + void deleteThemeWhenUnauthorized() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(true); + + // when & then + mockMvc.perform(delete("/admin/themes/{id}", id) + .header("X-ADMIN-TOKEN", "wrong-token")) + .andExpect(status().isUnauthorized()); + + verify(themeService, never()).deleteTheme(id); + } + + @Test + @DisplayName("이미 예약이 존재하는 테마는 삭제할 수 없다.") + void deleteThemeWhenThemeInUse() throws Exception { + // given + Long id = 1L; + when(validator.isUnauthorized(any(HttpServletRequest.class))).thenReturn(false); + willThrow(new ConflictException(ThemeErrors.THEME_IN_USE)) + .given(themeService) + .deleteTheme(id); + + // when & then + mockMvc.perform(delete("/admin/themes/{id}", id) + .header("X-ADMIN-TOKEN", "token")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("THEME_IN_USE")) + .andExpect(jsonPath("$.message").value("이미 예약이 존재하는 테마는 삭제할 수 없습니다.")); + } +} diff --git a/src/test/java/roomescape/domain/theme/admin/AdminThemeIntegrationTest.java b/src/test/java/roomescape/domain/theme/admin/AdminThemeIntegrationTest.java new file mode 100644 index 0000000000..8c4497c958 --- /dev/null +++ b/src/test/java/roomescape/domain/theme/admin/AdminThemeIntegrationTest.java @@ -0,0 +1,171 @@ +package roomescape.domain.theme.admin; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AdminThemeIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${token}") + private String adminToken; + + @BeforeEach + void setUp() { + RestAssured.port = port; + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_date"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + } + + @Test + @DisplayName("관리자의 테마 전체 조회를 end-to-end로 확인한다.") + void getAllThemeForAdmin() { + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + "공포", "무서운 테마", "theme-url" + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/themes") + .then().log().all() + .statusCode(200) + .body("[0].name", is("공포")) + .body("[0].content", is("무서운 테마")) + .body("[0].url", is("theme-url")); + } + + @Test + @DisplayName("관리자가 토큰을 누락했을 경우 401 예외가 발생한다.") + void getAllThemeForAdminWithoutToken() { + given().log().all() + .contentType(ContentType.JSON) + .when().get("/admin/themes") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 테마 생성을 end-to-end로 확인한다.") + void createTheme() { + String request = """ + { + "name" : "공포", + "content": "두렵다", + "url": "theme-scare" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .body(request) + .when().post("/admin/themes") + .then().log().all() + .statusCode(201) + .body("name", is("공포")) + .body("content", is("두렵다")) + .body("url", is("theme-scare")); + + given() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/themes") + .then() + .statusCode(200) + .body("name", hasItem("공포")) + .body("content", hasItem("두렵다")) + .body("url", hasItem("theme-scare")); + } + + @Test + @DisplayName("관리자 테마 생성 시 내용 필드가 누락되었을 경우 400 에러가 발생한다.") + void createThemeWithOutContent() { + String request = """ + { + "name" : "공포", + "url": "theme-scare" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .body(request) + .when().post("/admin/themes") + .then().log().all() + .statusCode(400) + .body("code", is("INPUT_VALIDATION_ERROR")) + .body("message", is("테마 내용은 비어있을 수 없습니다.")); + } + + @Test + @DisplayName("관리자 테마 생성 시 관리자 인증 토큰이 누락되었을 경우 401 에러가 발생한다.") + void createThemeWithOutToken() { + String request = """ + { + "name" : "공포", + "content": "두렵다", + "url": "theme-scare" + } + """; + + given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/admin/themes") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("관리자의 테마 삭제를 end-to-end로 확인한다.") + void deleteTheme() { + jdbcTemplate.update( + "INSERT INTO theme(name, content, url) VALUES (?, ?, ?)", + "공포", "무서운 테마", "theme-url" + ); + + Long themeId = jdbcTemplate.queryForObject( + "SELECT id FROM theme WHERE name = ?", + Long.class, + "공포" + ); + + given().log().all() + .contentType(ContentType.JSON) + .header("X-ADMIN-TOKEN", adminToken) + .when().delete("/admin/themes/{id}", themeId) + .then().log().all() + .statusCode(204); + + given() + .header("X-ADMIN-TOKEN", adminToken) + .when().get("/admin/themes") + .then() + .statusCode(200) + .body("name", not(hasItem("공포"))); + } +} diff --git a/src/test/java/roomescape/support/exception/GlobalExceptionHandlerTest.java b/src/test/java/roomescape/support/exception/GlobalExceptionHandlerTest.java index 51455827dd..f25362180d 100644 --- a/src/test/java/roomescape/support/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/roomescape/support/exception/GlobalExceptionHandlerTest.java @@ -2,16 +2,19 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.ResponseEntity; +import roomescape.support.exception.errors.ReservationErrors; class GlobalExceptionHandlerTest { @Test - void RoomescapeException을_에러_응답으로_변환한다() { + @DisplayName("RoomescapeException을 에러 응답으로 변환한다.") + void convertRoomescapeExceptionToErrorResponse() { // given GlobalExceptionHandler globalExceptionHandler = new GlobalExceptionHandler(); - BadRequestException exception = new BadRequestException(ReservationErrorCode.INVALID_RESERVATION_NAME); + BadRequestException exception = new BadRequestException(ReservationErrors.INVALID_RESERVATION_NAME); // when ResponseEntity response = globalExceptionHandler.handleBadRequestException(exception); @@ -25,7 +28,8 @@ class GlobalExceptionHandlerTest { } @Test - void 예상하지_못한_예외를_500_에러_응답으로_변환한다() { + @DisplayName("예상하지 못한 예외를 500 에러 응답으로 변환한다.") + void convertUnexpectedExceptionToInternalServerErrorResponse() { // given GlobalExceptionHandler globalExceptionHandler = new GlobalExceptionHandler(); Exception exception = new IllegalStateException(); @@ -37,7 +41,7 @@ class GlobalExceptionHandlerTest { assertSoftly(softly -> { softly.assertThat(response.getStatusCode().value()).isEqualTo(500); softly.assertThat(response.getBody().code()).isEqualTo("INTERNAL_SERVER_ERROR"); - softly.assertThat(response.getBody().message()).isEqualTo("서버 내부 오류가 발생했습니다"); + softly.assertThat(response.getBody().message()).isEqualTo("서버 내부 오류가 발생했습니다."); }); } } diff --git a/src/test/java/roomescape/support/fake/FakeReservationDateRepository.java b/src/test/java/roomescape/support/fake/FakeReservationDateRepository.java index 31ec4939f9..3fb44cd073 100644 --- a/src/test/java/roomescape/support/fake/FakeReservationDateRepository.java +++ b/src/test/java/roomescape/support/fake/FakeReservationDateRepository.java @@ -1,36 +1,55 @@ package roomescape.support.fake; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationdate.ReservationDateRepository; public class FakeReservationDateRepository implements ReservationDateRepository { - public ReservationDate reservationDate; - public ReservationDate savedReservationDate; - public List findAllResult = List.of(); - public Long deletedId; + private final Map storage = new LinkedHashMap<>(); + private long sequence = 1L; @Override public Optional findById(Long id) { - return Optional.ofNullable(reservationDate); + return Optional.ofNullable(storage.get(id)); } @Override public List findAll() { - return findAllResult; + return new ArrayList<>(storage.values()); } @Override public ReservationDate save(ReservationDate reservationDate) { - savedReservationDate = reservationDate; - return ReservationDate.of(1L, reservationDate.getDate()); + Long id = reservationDate.getId(); + if (id == null) { + id = sequence++; + } else { + sequence = Math.max(sequence, id + 1); + } + ReservationDate savedReservationDate = ReservationDate.of(id, reservationDate.getDate()); + storage.put(id, savedReservationDate); + return savedReservationDate; } @Override public int deleteById(Long id) { - deletedId = id; + ReservationDate removedReservationDate = storage.remove(id); + if (removedReservationDate == null) { + return 0; + } return 1; } + + @Override + public Optional findByDate(LocalDate startWhen) { + return storage.values().stream() + .filter(reservationDate -> startWhen.equals(reservationDate.getDate())) + .findFirst(); + } } diff --git a/src/test/java/roomescape/support/fake/FakeReservationRepository.java b/src/test/java/roomescape/support/fake/FakeReservationRepository.java index ddde77f5ac..d7b814d353 100644 --- a/src/test/java/roomescape/support/fake/FakeReservationRepository.java +++ b/src/test/java/roomescape/support/fake/FakeReservationRepository.java @@ -1,54 +1,83 @@ package roomescape.support.fake; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationRepository; import roomescape.domain.theme.Theme; public class FakeReservationRepository implements ReservationRepository { - public Reservation savedReservation; - public List findAllResult = List.of(); - public int countByTimeIdResult; - public int countByReservationDateIdResult; - public int countByThemeIdResult; + private final Map storage = new LinkedHashMap<>(); + private long sequence = 1L; + + public Optional findById(Long id) { + return Optional.ofNullable(storage.get(id)); + } @Override public Reservation save(Reservation reservation) { - savedReservation = reservation; - return Reservation.of( - 1L, - reservation.getName(), - reservation.getDate(), - reservation.getTime(), - reservation.getTheme() - ); + Long id = reservation.getId(); + if (id == null) { + id = sequence++; + } else { + sequence = Math.max(sequence, id + 1); + } + Reservation savedReservation = Reservation.createWithId(id, reservation); + storage.put(id, savedReservation); + return savedReservation; } @Override public List findAll() { - return findAllResult; + return new ArrayList<>(storage.values()); } @Override public int deleteById(Long id) { - return 0; + Reservation removedReservation = storage.remove(id); + if (removedReservation == null) { + return 0; + } + return 1; } @Override public int countByTimeId(Long timeId) { - return countByTimeIdResult; + int count = 0; + for (Reservation value : storage.values()) { + if (value.getTime().getId().equals(timeId)) { + count++; + } + } + return count; } @Override public int countByReservationDateId(Long dateId) { - return countByReservationDateIdResult; + int count = 0; + for (Reservation value : storage.values()) { + if (value.getDate().getId().equals(dateId)) { + count++; + } + } + return count; } @Override public List findReservedTimes(Long themeId, Long dateId) { - return List.of(); + List reservedTimeIds = new ArrayList<>(); + for (Reservation reservation : storage.values()) { + if (reservation.getTheme().getId().equals(themeId) && reservation.getDate().getId().equals(dateId)) { + reservedTimeIds.add(reservation.getTime().getId()); + } + } + return reservedTimeIds; } @Override @@ -57,7 +86,64 @@ public List findPopularThemes(int rankLimit, LocalDate startDay, LocalDat } @Override - public int countByThemeId(Long id) { - return countByThemeIdResult; + public int countByThemeId(Long themeId) { + int count = 0; + for (Reservation reservation : storage.values()) { + if (reservation.getTheme().getId().equals(themeId)) { + count++; + } + } + return count; + } + + @Override + public boolean existsReservation(Long timeId, Long dateId, Long themeId) { + for (Reservation reservation : storage.values()) { + if (timeId.equals(reservation.getTime().getId()) + && dateId.equals(reservation.getDate().getId()) + && themeId.equals(reservation.getTheme().getId())) { + return true; + } + } + return false; + } + + @Override + public boolean existsOtherReservation(Long id, Long timeId, Long dateId, Long themeId) { + for (Reservation reservation : storage.values()) { + if (!id.equals(reservation.getId()) + && timeId.equals(reservation.getTime().getId()) + && dateId.equals(reservation.getDate().getId()) + && themeId.equals(reservation.getTheme().getId())) { + return true; + } + } + return false; + } + + @Override + public List findByName(String name) { + List reservations = new ArrayList<>(storage.values().stream() + .filter(reservation -> name.equals(reservation.getName())) + .toList()); + reservations.sort(latestReservationFirst()); + return reservations; + } + + @Override + public Optional update(Long id, Reservation withoutId) { + if (!storage.containsKey(id)) { + return Optional.empty(); + } + Reservation updatedReservation = Reservation.createWithId(id, withoutId); + storage.put(id, updatedReservation); + return Optional.of(updatedReservation); + } + + private Comparator latestReservationFirst() { + return Comparator.comparing((Reservation reservation) -> reservation.getDate().getDate()) + .reversed() + .thenComparing(reservation -> reservation.getTime().getStartAt(), Comparator.reverseOrder()) + .thenComparing(Reservation::getId, Comparator.reverseOrder()); } } diff --git a/src/test/java/roomescape/support/fake/FakeReservationTimeRepository.java b/src/test/java/roomescape/support/fake/FakeReservationTimeRepository.java index de138f080a..8ab8759081 100644 --- a/src/test/java/roomescape/support/fake/FakeReservationTimeRepository.java +++ b/src/test/java/roomescape/support/fake/FakeReservationTimeRepository.java @@ -1,36 +1,55 @@ package roomescape.support.fake; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.reservationtime.ReservationTimeRepository; public class FakeReservationTimeRepository implements ReservationTimeRepository { - public ReservationTime reservationTime; - public ReservationTime savedReservationTime; - public List findAllResult = List.of(); - public Long deletedId; + private final Map storage = new LinkedHashMap<>(); + private long sequence = 1L; @Override public Optional findById(Long id) { - return Optional.ofNullable(reservationTime); + return Optional.ofNullable(storage.get(id)); } @Override public ReservationTime save(ReservationTime reservationTime) { - savedReservationTime = reservationTime; - return ReservationTime.of(1L, reservationTime.getStartAt()); + Long id = reservationTime.getId(); + if (id == null) { + id = sequence++; + } else { + sequence = Math.max(sequence, id + 1); + } + ReservationTime savedReservationTime = ReservationTime.of(id, reservationTime.getStartAt()); + storage.put(id, savedReservationTime); + return savedReservationTime; } @Override public List findAll() { - return findAllResult; + return new ArrayList<>(storage.values()); } @Override public int deleteById(Long id) { - deletedId = id; + ReservationTime removedReservationTime = storage.remove(id); + if (removedReservationTime == null) { + return 0; + } return 1; } + + @Override + public Optional findByStartAt(LocalTime startAt) { + return storage.values().stream() + .filter(reservationTime -> startAt.equals(reservationTime.getStartAt())) + .findFirst(); + } } diff --git a/src/test/java/roomescape/support/fake/FakeThemeRepository.java b/src/test/java/roomescape/support/fake/FakeThemeRepository.java index 8d46629247..8b7a17467c 100644 --- a/src/test/java/roomescape/support/fake/FakeThemeRepository.java +++ b/src/test/java/roomescape/support/fake/FakeThemeRepository.java @@ -1,41 +1,47 @@ package roomescape.support.fake; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import roomescape.domain.theme.Theme; import roomescape.domain.theme.ThemeRepository; public class FakeThemeRepository implements ThemeRepository { - public List findAllResult = List.of(); - public Theme theme; - public Theme savedTheme; - public Long deletedId; + private final Map storage = new LinkedHashMap<>(); + private long sequence = 1L; @Override public List findAll() { - return findAllResult; + return new ArrayList<>(storage.values()); } @Override public Optional findById(Long id) { - if (theme != null) { - return Optional.of(theme); - } - return findAllResult.stream() - .filter(candidate -> candidate.getId().equals(id)) - .findFirst(); + return Optional.ofNullable(storage.get(id)); } @Override public Theme save(Theme theme) { - savedTheme = theme; - return Theme.of(1L, theme.getName(), theme.getContent(), theme.getUrl()); + Long id = theme.getId(); + if (id == null) { + id = sequence++; + } else { + sequence = Math.max(sequence, id + 1); + } + Theme savedTheme = Theme.of(id, theme.getName(), theme.getContent(), theme.getUrl()); + storage.put(id, savedTheme); + return savedTheme; } @Override public int deleteById(Long id) { - deletedId = id; + Theme removedTheme = storage.remove(id); + if (removedTheme == null) { + return 0; + } return 1; } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000000..ea24942fa4 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,5 @@ +spring: + datasource: + url: jdbc:h2:mem:${random.uuid};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + +token: ""