diff --git a/.gitignore b/.gitignore index c2065bc262..68988d4c83 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +.DS_Store +.gitmessage.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000000..7a605bf692 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# 1단계 - 예외 처리와 응답 +## 예약 +- [x] 지나간 날짜와 시간에 대한 예약 생성 시 예외가 발생한다. +- [x] 중복 예약 시 예외가 발생한다. +- [x] 예약 생성 시 예약자명이 비어있으면 예외가 발생한다. +- [x] 날짜가 유효하지 않은 값일 시 예외가 발생한다. +- [x] 등록되지 않은 시간에 대한 예약 생성 시 예외가 발생한다. +- [x] 요청 본문이 JSON 형식이 아닐 경우 예외가 발생한다. + +## 시간 +- [x] 중복된 시간 생성 시 예외가 발생한다. +- [x] 시간 삭제 시 참조된 예약이 있으면 예외가 발생한다. +- [x] 시간이 유효하지 않은 값일 시 예외가 발생한다. +- [x] 요청 본문이 JSON 형식이 아닐 경우 예외가 발생한다. + +# 2단계 - 테마 추가 +## 테마 +- [x] 관리자는 테마를 추가할 수 있다. + - [x] 중복된 테마 이름으로 추가 시 예외가 발생한다. + - [x] 테마 추가 시, 테마 이름, 설명, 썸네일 중 하나라도 비어 있으면 예외가 발생한다. +- [x] 관리자는 테마를 조회할 수 있다. +- [x] 관리자는 테마를 삭제할 수 있다. + - [x] 테마 삭제 시 참조된 예약이 있으면 예외가 발생한다. + +## 예약 +- [x] 방탈출 예약 시 테마 정보를 포함하여 예약을 추가한다. + - [x] 등록되지 않은 테마에 대한 예약 생성 시 예외가 발생한다. + - [x] 동시간대에 이미 예약된 테마를 예약하는 경우 예외가 발생한다. + +## 화면 +- [x] `/admin/theme` 요청 시 `templates/admin/theme.html`가 응답한다. +- [x] `/admin/reservation` 요청 시 `templates/admin/reservation-new.html`가 응답한다. + +# 3단계 - 사용자 기능 +## 예약 +- [x] `/reservation` 요청 시 `templates/reservation.html`가 응답한다. + +## 테마 +- [x] `/` 요청 시 `templates/index.html`가 응답한다. +- [x] 최근 일주일을 기준으로 예약이 많은 상위 10개 테마를 조회할 수 있다. + +## 시간 +- [x] 선택된 테마에 따라 예약 가능 여부가 포함된 시간을 조회한다. diff --git a/src/main/java/roomescape/RoomescapeApplication.java b/src/main/java/roomescape/RoomescapeApplication.java index 5fcfdde5d6..ba6fc0a6a4 100644 --- a/src/main/java/roomescape/RoomescapeApplication.java +++ b/src/main/java/roomescape/RoomescapeApplication.java @@ -9,5 +9,4 @@ public class RoomescapeApplication { public static void main(String[] args) { SpringApplication.run(RoomescapeApplication.class, args); } - } diff --git a/src/main/java/roomescape/controller/AdminController.java b/src/main/java/roomescape/controller/AdminController.java new file mode 100644 index 0000000000..87e61ebae8 --- /dev/null +++ b/src/main/java/roomescape/controller/AdminController.java @@ -0,0 +1,30 @@ +package roomescape.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin") +public class AdminController { + + @GetMapping + public String readAdmin() { + return "admin/index"; + } + + @GetMapping("/reservation") + public String readReservations() { + return "admin/reservation-new"; + } + + @GetMapping("/time") + public String readTimes() { + return "admin/time"; + } + + @GetMapping("/theme") + public String readTheme() { + return "admin/theme"; + } +} diff --git a/src/main/java/roomescape/controller/ReservationController.java b/src/main/java/roomescape/controller/ReservationController.java new file mode 100644 index 0000000000..e9565975af --- /dev/null +++ b/src/main/java/roomescape/controller/ReservationController.java @@ -0,0 +1,39 @@ +package roomescape.controller; + +import org.springframework.web.bind.annotation.*; +import roomescape.service.dto.reservation.ReservationCreateRequest; +import roomescape.service.dto.reservation.ReservationResponse; +import roomescape.service.ReservationService; + +import java.util.List; + +@RestController +@RequestMapping("/reservations") +public class ReservationController { + + private final ReservationService reservationService; + + public ReservationController(ReservationService reservationService) { + this.reservationService = reservationService; + } + + @GetMapping + public List readReservations() { + return reservationService.readReservations(); + } + + @GetMapping("/{id}") + public ReservationResponse readReservation(@PathVariable Long id) { + return reservationService.readReservation(id); + } + + @PostMapping + public ReservationResponse createReservation(@RequestBody ReservationCreateRequest request) { + return reservationService.createReservation(request); + } + + @DeleteMapping("/{id}") + public void deleteReservation(@PathVariable Long id) { + reservationService.deleteReservation(id); + } +} diff --git a/src/main/java/roomescape/controller/ReservationTimeController.java b/src/main/java/roomescape/controller/ReservationTimeController.java new file mode 100644 index 0000000000..fc8d8a93e8 --- /dev/null +++ b/src/main/java/roomescape/controller/ReservationTimeController.java @@ -0,0 +1,49 @@ +package roomescape.controller; + +import org.springframework.web.bind.annotation.*; +import roomescape.service.dto.reservationtime.ReservationTimeCreateRequest; +import roomescape.service.dto.reservationtime.ReservationTimeResponse; +import roomescape.service.ReservationTimeService; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/times") +public class ReservationTimeController { + + private final ReservationTimeService reservationTimeService; + + public ReservationTimeController(ReservationTimeService reservationTimeService) { + this.reservationTimeService = reservationTimeService; + } + + @GetMapping + public List readTimes() { + return reservationTimeService.readReservationTimes(); + } + + @GetMapping(params = {"date", "themeId"}) + public List readTimes( + @RequestParam(value = "date") LocalDate date, + @RequestParam(value = "themeId") Long themeId + ) { + return reservationTimeService.readReservationTimes(date, themeId); + } + + @GetMapping("/{id}") + public ReservationTimeResponse readTime(@PathVariable Long id) { + return reservationTimeService.readReservationTime(id); + + } + + @PostMapping + public ReservationTimeResponse createTime(@RequestBody ReservationTimeCreateRequest request) { + return reservationTimeService.createTime(request); + } + + @DeleteMapping("/{id}") + public void deleteTime(@PathVariable Long id) { + reservationTimeService.deleteTime(id); + } +} diff --git a/src/main/java/roomescape/controller/ThemeController.java b/src/main/java/roomescape/controller/ThemeController.java new file mode 100644 index 0000000000..db906a4269 --- /dev/null +++ b/src/main/java/roomescape/controller/ThemeController.java @@ -0,0 +1,51 @@ +package roomescape.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import roomescape.service.dto.theme.ThemeCreateRequest; +import roomescape.service.dto.theme.ThemeResponse; +import roomescape.service.ThemeService; + +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/themes") +public class ThemeController { + private final ThemeService themeService; + + public ThemeController(ThemeService themeService) { + this.themeService = themeService; + } + + @PostMapping + public ResponseEntity createTheme(@RequestBody ThemeCreateRequest themeCreateRequest) { + ThemeResponse theme = themeService.createTheme(themeCreateRequest); + return ResponseEntity.created(URI.create("/themes/" + theme.id())) + .body(theme); + } + + @GetMapping + public ResponseEntity> readThemes() { + List themes = themeService.readThemes(); + return ResponseEntity.ok(themes); + } + + @GetMapping("/{id}") + public ResponseEntity readTheme(@PathVariable Long id) { + ThemeResponse themeResponse = themeService.readTheme(id); + return ResponseEntity.ok(themeResponse); + } + + @GetMapping("/popular") + public ResponseEntity> readPopularThemes() { + List themeResponses = themeService.readPopularThemes(); + return ResponseEntity.ok(themeResponses); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTheme(@PathVariable Long id) { + themeService.deleteTheme(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/roomescape/controller/UserReservationController.java b/src/main/java/roomescape/controller/UserReservationController.java new file mode 100644 index 0000000000..3cb9d133d3 --- /dev/null +++ b/src/main/java/roomescape/controller/UserReservationController.java @@ -0,0 +1,15 @@ +package roomescape.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + + +@Controller +@RequestMapping("/reservation") +public class UserReservationController { + @GetMapping + public String readUserReservation() { + return "reservation"; + } +} diff --git a/src/main/java/roomescape/domain/Reservation.java b/src/main/java/roomescape/domain/Reservation.java new file mode 100644 index 0000000000..cb2bd6bb68 --- /dev/null +++ b/src/main/java/roomescape/domain/Reservation.java @@ -0,0 +1,64 @@ +package roomescape.domain; + +import java.time.LocalDate; +import roomescape.exception.EmptyParameterException; + +public class Reservation { + + private final Long id; + private final String name; + private final LocalDate date; + private final ReservationTime time; + private final Theme theme; + + public Reservation(Long id, String name, LocalDate date, ReservationTime time, Theme theme) { + validateName(name); + this.id = id; + this.name = name; + this.date = date; + this.time = time; + this.theme = theme; + } + + private void validateName(String name) { + if (name.isBlank()) { + throw new EmptyParameterException("이름은 공백일 수 없습니다."); + } + } + + public Reservation(String name, LocalDate date, ReservationTime reservationTime, Theme theme) { + this(null, name, date, reservationTime, theme); + } + + public Reservation(Long id, Reservation reservation) { + this(id, reservation.name, reservation.date, reservation.time, reservation.theme); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public LocalDate getDate() { + return date; + } + + public ReservationTime getTime() { + return time; + } + + public Theme getTheme() { + return theme; + } + + public Long getTimeId() { + return time.getId(); + } + + public Long getThemeId() { + return theme.getId(); + } +} diff --git a/src/main/java/roomescape/domain/ReservationTime.java b/src/main/java/roomescape/domain/ReservationTime.java new file mode 100644 index 0000000000..df325be343 --- /dev/null +++ b/src/main/java/roomescape/domain/ReservationTime.java @@ -0,0 +1,30 @@ +package roomescape.domain; + +import java.time.LocalTime; + +public class ReservationTime { + + private final Long id; + private final LocalTime startAt; + + public ReservationTime(Long id, LocalTime startAt) { + this.id = id; + this.startAt = startAt; + } + + public ReservationTime(LocalTime startAt) { + this(null, startAt); + } + + public boolean isDuplicated(ReservationTime other) { + return startAt.equals(other.startAt); + } + + public Long getId() { + return id; + } + + public LocalTime getStartAt() { + return startAt; + } +} diff --git a/src/main/java/roomescape/domain/Theme.java b/src/main/java/roomescape/domain/Theme.java new file mode 100644 index 0000000000..4b2f34b13a --- /dev/null +++ b/src/main/java/roomescape/domain/Theme.java @@ -0,0 +1,71 @@ +package roomescape.domain; + +import roomescape.exception.EmptyParameterException; + +public class Theme { + + private final Long id; + private final String name; + private final String description; + private final String thumbnail; + + public Theme(Long id, String name, String description, String thumbnail) { + validateNotBlank(name, description, thumbnail); + this.id = id; + this.name = name; + this.description = description; + this.thumbnail = thumbnail; + } + + private void validateNotBlank(String name, String description, String thumbnail) { + if (name.isBlank() || description.isBlank() || thumbnail.isBlank()) { + throw new EmptyParameterException("테마의 정보는 비어있을 수 없습니다."); + } + } + + public Theme(String name, String description, String thumbnail) { + this(null, name, description, thumbnail); + } + + public Theme(Long id, Theme theme) { + this(id, theme.name, theme.description, theme.thumbnail); + } + + public boolean isDuplicated(Theme theme) { + return name.equals(theme.name); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getThumbnail() { + return thumbnail; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Theme theme = (Theme) o; + return getId().equals(theme.getId()); + } + + @Override + public int hashCode() { + return getId().hashCode(); + } +} diff --git a/src/main/java/roomescape/exception/BadRequestException.java b/src/main/java/roomescape/exception/BadRequestException.java new file mode 100644 index 0000000000..1aeb8e6321 --- /dev/null +++ b/src/main/java/roomescape/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class BadRequestException extends RuntimeException { + protected BadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/DuplicatedException.java b/src/main/java/roomescape/exception/DuplicatedException.java new file mode 100644 index 0000000000..3d62486f5b --- /dev/null +++ b/src/main/java/roomescape/exception/DuplicatedException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class DuplicatedException extends BadRequestException { + public DuplicatedException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/EmptyParameterException.java b/src/main/java/roomescape/exception/EmptyParameterException.java new file mode 100644 index 0000000000..09e53ff56a --- /dev/null +++ b/src/main/java/roomescape/exception/EmptyParameterException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class EmptyParameterException extends BadRequestException { + public EmptyParameterException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/GlobalExceptionHandler.java b/src/main/java/roomescape/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000000..309fd64d78 --- /dev/null +++ b/src/main/java/roomescape/exception/GlobalExceptionHandler.java @@ -0,0 +1,41 @@ +package roomescape.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import roomescape.exception.dto.ErrorResponse; + +import java.time.DateTimeException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleResourceNotFoundException(ResourceNotFoundException exception) { + ErrorResponse data = new ErrorResponse(HttpStatus.NOT_FOUND, exception.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(data); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(BadRequestException exception) { + ErrorResponse data = new ErrorResponse(HttpStatus.BAD_REQUEST, exception.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(data); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException exception) { + if (exception.getRootCause() instanceof DateTimeException) { + return handleDateTimeParseException(); + } + + ErrorResponse data = new ErrorResponse(HttpStatus.BAD_REQUEST, "요청에 잘못된 형식의 값이 있습니다."); + return ResponseEntity.badRequest().body(data); + } + + private ResponseEntity handleDateTimeParseException() { + ErrorResponse data = new ErrorResponse(HttpStatus.BAD_REQUEST, "잘못된 형식의 날짜 혹은 시간입니다."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(data); + } +} diff --git a/src/main/java/roomescape/exception/IllegalTimeException.java b/src/main/java/roomescape/exception/IllegalTimeException.java new file mode 100644 index 0000000000..fe4e4de9b5 --- /dev/null +++ b/src/main/java/roomescape/exception/IllegalTimeException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class IllegalTimeException extends BadRequestException { + public IllegalTimeException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/ReferencedReservationExistException.java b/src/main/java/roomescape/exception/ReferencedReservationExistException.java new file mode 100644 index 0000000000..ae5603aab0 --- /dev/null +++ b/src/main/java/roomescape/exception/ReferencedReservationExistException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class ReferencedReservationExistException extends BadRequestException { + public ReferencedReservationExistException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/ResourceNotFoundException.java b/src/main/java/roomescape/exception/ResourceNotFoundException.java new file mode 100644 index 0000000000..9d6960d360 --- /dev/null +++ b/src/main/java/roomescape/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/dto/ErrorResponse.java b/src/main/java/roomescape/exception/dto/ErrorResponse.java new file mode 100644 index 0000000000..d3b39e05c7 --- /dev/null +++ b/src/main/java/roomescape/exception/dto/ErrorResponse.java @@ -0,0 +1,6 @@ +package roomescape.exception.dto; + +import org.springframework.http.HttpStatus; + +public record ErrorResponse(HttpStatus code, String detail) { +} diff --git a/src/main/java/roomescape/repository/reservation/ReservationDao.java b/src/main/java/roomescape/repository/reservation/ReservationDao.java new file mode 100644 index 0000000000..cf4cb904e8 --- /dev/null +++ b/src/main/java/roomescape/repository/reservation/ReservationDao.java @@ -0,0 +1,178 @@ +package roomescape.repository.reservation; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import org.springframework.stereotype.Repository; +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.domain.Theme; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public class ReservationDao implements ReservationRepository { + private final JdbcTemplate jdbcTemplate; + private final SimpleJdbcInsert simpleJdbcInsert; + + private static final RowMapper rowMapper = (resultSet, rowNum) -> new Reservation( + resultSet.getLong("id"), + resultSet.getString("name"), + resultSet.getDate("date").toLocalDate(), + new ReservationTime( + resultSet.getLong("time_id"), + resultSet.getTime("start_at").toLocalTime() + ), + new Theme( + resultSet.getLong("theme_id"), + resultSet.getString("theme_name"), + resultSet.getString("theme_description"), + resultSet.getString("theme_thumbnail") + ) + ); + + public ReservationDao(JdbcTemplate jdbcTemplate, DataSource dataSource) { + this.jdbcTemplate = jdbcTemplate; + this.simpleJdbcInsert = new SimpleJdbcInsert(dataSource) + .withTableName("reservation") + .usingGeneratedKeyColumns("id"); + } + + @Override + public Reservation save(Reservation reservation) { + SqlParameterSource sqlParameterSource = new MapSqlParameterSource() + .addValue("name", reservation.getName()) + .addValue("date", reservation.getDate()) + .addValue("time_id", reservation.getTime().getId()) + .addValue("theme_id", reservation.getTheme().getId()); + + Long id = simpleJdbcInsert.executeAndReturnKey(sqlParameterSource).longValue(); + + return new Reservation(id, reservation); + } + + @Override + public List findAll() { + String sql = """ + SELECT + r.id AS reservation_id, + r.name, + r.date, + t.id AS time_id, + t.start_at AS start_at, + th.id AS theme_id, + th.name AS theme_name, + th.description AS theme_description, + th.thumbnail AS theme_thumbnail + FROM reservation AS r + INNER JOIN reservation_time AS t + ON r.time_id = t.id + INNER JOIN theme AS th + ON r.theme_id = th.id"""; + return jdbcTemplate.query(sql, rowMapper); + } + + @Override + public Optional findById(Long id) { + try { + String sql = """ + SELECT + r.id AS reservation_id, + r.name, + r.date, + t.id AS time_id, + t.start_at AS start_at, + th.id AS theme_id, + th.name AS theme_name, + th.description AS theme_description, + th.thumbnail AS theme_thumbnail + FROM reservation AS r + INNER JOIN reservation_time AS t + ON r.time_id = t.id + INNER JOIN theme AS th + ON r.theme_id = th.id + WHERE r.id = ?"""; + Reservation reservation = jdbcTemplate.queryForObject(sql, rowMapper, id); + return Optional.ofNullable(reservation); + } catch (EmptyResultDataAccessException exception) { + return Optional.empty(); + } + } + + @Override + public List findByDateBetween(LocalDate start, LocalDate end) { + String sql = """ + SELECT + r.id AS reservation_id, + r.name, + r.date, + t.id AS time_id, + t.start_at AS start_at, + th.id AS theme_id, + th.name AS theme_name, + th.description AS theme_description, + th.thumbnail AS theme_thumbnail + FROM reservation AS r + INNER JOIN reservation_time AS t + ON r.time_id = t.id + INNER JOIN theme AS th + ON r.theme_id = th.id + WHERE r.date BETWEEN ? AND ?"""; + return jdbcTemplate.query(sql, rowMapper, start, end); + } + + @Override + public List findByDateAndThemeId(LocalDate date, Long themeId) { + String sql = """ + SELECT + r.id AS reservation_id, + r.name, + r.date, + t.id AS time_id, + t.start_at AS start_at, + th.id AS theme_id, + th.name AS theme_name, + th.description AS theme_description, + th.thumbnail AS theme_thumbnail + FROM reservation AS r + INNER JOIN reservation_time AS t + ON r.time_id = t.id + INNER JOIN theme AS th + ON r.theme_id = th.id + WHERE r.date = ? AND th.id = ?"""; + return jdbcTemplate.query(sql, rowMapper, date, themeId); + } + + @Override + public void deleteById(Long id) { + String sql = "DELETE FROM reservation WHERE id = ?"; + jdbcTemplate.update(sql, id); + } + + @Override + public boolean existsByTimeId(Long timeId) { + String sql = "SELECT EXISTS(SELECT id FROM reservation WHERE time_id = ?)"; + Boolean isExist = jdbcTemplate.queryForObject(sql, Boolean.class, timeId); + return Boolean.TRUE.equals(isExist); + } + + @Override + public boolean existsByThemeId(Long themeId) { + String sql = "SELECT EXISTS(SELECT id FROM reservation WHERE theme_id = ?)"; + Boolean isExist = jdbcTemplate.queryForObject(sql, Boolean.class, themeId); + return Boolean.TRUE.equals(isExist); + } + + @Override + public boolean existsBy(LocalDate date, Long timeId, Long themeId) { + String sql = "SELECT EXISTS(SELECT id FROM reservation WHERE date = ? AND time_id = ? AND theme_id = ?)"; + Boolean isExist = jdbcTemplate.queryForObject(sql, Boolean.class, date, timeId, themeId); + return Boolean.TRUE.equals(isExist); + } +} diff --git a/src/main/java/roomescape/repository/reservation/ReservationRepository.java b/src/main/java/roomescape/repository/reservation/ReservationRepository.java new file mode 100644 index 0000000000..da68ea7886 --- /dev/null +++ b/src/main/java/roomescape/repository/reservation/ReservationRepository.java @@ -0,0 +1,28 @@ +package roomescape.repository.reservation; + +import roomescape.domain.Reservation; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ReservationRepository { + + Reservation save(Reservation reservation); + + List findAll(); + + Optional findById(Long id); + + List findByDateBetween(LocalDate start, LocalDate end); + + List findByDateAndThemeId(LocalDate date, Long themeId); + + void deleteById(Long id); + + boolean existsByTimeId(Long timeId); + + boolean existsByThemeId(Long themeId); + + boolean existsBy(LocalDate date, Long timeId, Long themeId); +} diff --git a/src/main/java/roomescape/repository/reservationtime/ReservationTimeDao.java b/src/main/java/roomescape/repository/reservationtime/ReservationTimeDao.java new file mode 100644 index 0000000000..51de89690d --- /dev/null +++ b/src/main/java/roomescape/repository/reservationtime/ReservationTimeDao.java @@ -0,0 +1,63 @@ +package roomescape.repository.reservationtime; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import org.springframework.stereotype.Repository; +import roomescape.domain.ReservationTime; + +import javax.sql.DataSource; +import java.util.List; +import java.util.Optional; + +@Repository +public class ReservationTimeDao implements ReservationTimeRepository { + private final JdbcTemplate jdbcTemplate; + private final SimpleJdbcInsert simpleJdbcInsert; + + private static final RowMapper rowMapper = (resultSet, rowNum) -> new ReservationTime( + resultSet.getLong("id"), + resultSet.getTime("start_at").toLocalTime() + ); + + public ReservationTimeDao(JdbcTemplate jdbcTemplate, DataSource dataSource) { + this.jdbcTemplate = jdbcTemplate; + this.simpleJdbcInsert = new SimpleJdbcInsert(dataSource) + .withTableName("reservation_time") + .usingGeneratedKeyColumns("id"); + } + + @Override + public ReservationTime save(ReservationTime reservationTime) { + SqlParameterSource sqlParameterSource = new BeanPropertySqlParameterSource(reservationTime); + Long id = simpleJdbcInsert.executeAndReturnKey(sqlParameterSource).longValue(); + + return new ReservationTime(id, reservationTime.getStartAt()); + } + + @Override + public List findAll() { + String sql = "SELECT * FROM reservation_time"; + return jdbcTemplate.query(sql, rowMapper); + } + + @Override + public Optional findById(Long id) { + try { + String sql = "SELECT * FROM reservation_time WHERE id = ?"; + ReservationTime reservationTime = jdbcTemplate.queryForObject(sql, rowMapper, id); + return Optional.ofNullable(reservationTime); + } catch (EmptyResultDataAccessException exception) { + return Optional.empty(); + } + } + + @Override + public void deleteById(Long id) { + String sql = "DELETE FROM reservation_time WHERE id = ?"; + jdbcTemplate.update(sql, id); + } +} diff --git a/src/main/java/roomescape/repository/reservationtime/ReservationTimeRepository.java b/src/main/java/roomescape/repository/reservationtime/ReservationTimeRepository.java new file mode 100644 index 0000000000..02805e7981 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationtime/ReservationTimeRepository.java @@ -0,0 +1,17 @@ +package roomescape.repository.reservationtime; + +import roomescape.domain.ReservationTime; + +import java.util.List; +import java.util.Optional; + +public interface ReservationTimeRepository { + + ReservationTime save(ReservationTime reservationTime); + + List findAll(); + + Optional findById(Long id); + + void deleteById(Long id); +} diff --git a/src/main/java/roomescape/repository/theme/ThemeDao.java b/src/main/java/roomescape/repository/theme/ThemeDao.java new file mode 100644 index 0000000000..6df4d72953 --- /dev/null +++ b/src/main/java/roomescape/repository/theme/ThemeDao.java @@ -0,0 +1,65 @@ +package roomescape.repository.theme; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import org.springframework.stereotype.Repository; +import roomescape.domain.Theme; + +import java.util.List; +import java.util.Optional; + +@Repository +public class ThemeDao implements ThemeRepository { + private final JdbcTemplate jdbcTemplate; + private final SimpleJdbcInsert simpleJdbcInsert; + + private static final RowMapper rowMapper = (rs, rowNum) -> new Theme( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getString("thumbnail") + ); + + public ThemeDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + this.simpleJdbcInsert = new SimpleJdbcInsert(jdbcTemplate) + .withTableName("theme") + .usingGeneratedKeyColumns("id"); + } + + @Override + public Theme save(Theme theme) { + SqlParameterSource sqlParameterSource = new BeanPropertySqlParameterSource(theme); + Long id = simpleJdbcInsert.executeAndReturnKey(sqlParameterSource).longValue(); + return new Theme(id, theme); + } + + @Override + public List findAll() { + String sql = "SELECT * FROM theme"; + return jdbcTemplate.query(sql, rowMapper); + } + + @Override + public Optional findById(Long id) { + String sql = "SELECT * FROM theme WHERE id = ?"; + + try { + Theme theme = jdbcTemplate.queryForObject(sql, rowMapper, id); + return Optional.ofNullable(theme); + } catch (EmptyResultDataAccessException exception) { + return Optional.empty(); + } + } + + @Override + public void deleteById(Long id) { + String sql = "DELETE FROM theme WHERE id = ?"; + jdbcTemplate.update(sql, id); + } +} + diff --git a/src/main/java/roomescape/repository/theme/ThemeRepository.java b/src/main/java/roomescape/repository/theme/ThemeRepository.java new file mode 100644 index 0000000000..92499386b5 --- /dev/null +++ b/src/main/java/roomescape/repository/theme/ThemeRepository.java @@ -0,0 +1,17 @@ +package roomescape.repository.theme; + +import roomescape.domain.Theme; + +import java.util.List; +import java.util.Optional; + +public interface ThemeRepository { + + Theme save(Theme theme); + + List findAll(); + + Optional findById(Long id); + + void deleteById(Long id); +} diff --git a/src/main/java/roomescape/service/ReservationService.java b/src/main/java/roomescape/service/ReservationService.java new file mode 100644 index 0000000000..d54ed7c0df --- /dev/null +++ b/src/main/java/roomescape/service/ReservationService.java @@ -0,0 +1,87 @@ +package roomescape.service; + +import org.springframework.stereotype.Service; +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.domain.Theme; +import roomescape.exception.IllegalTimeException; +import roomescape.service.dto.reservation.ReservationCreateRequest; +import roomescape.service.dto.reservation.ReservationResponse; +import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.theme.ThemeRepository; +import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationTimeRepository reservationTimeRepository; + private final ThemeRepository themeRepository; + + public ReservationService( + ReservationRepository reservationRepository, + ReservationTimeRepository reservationTimeRepository, + ThemeRepository themeRepository + ) { + this.reservationRepository = reservationRepository; + this.reservationTimeRepository = reservationTimeRepository; + this.themeRepository = themeRepository; + } + + public ReservationResponse createReservation(ReservationCreateRequest request) { + ReservationTime reservationTime = findReservationTimeById(request.timeId()); + Theme theme = findThemeById(request.themeId()); + Reservation reservation = request.toReservation(reservationTime, theme); + + validateDuplicated(reservation); + validateRequestedTime(reservation, reservationTime); + + Reservation savedReservation = reservationRepository.save(reservation); + return ReservationResponse.from(savedReservation); + } + + private ReservationTime findReservationTimeById(Long id) { + return reservationTimeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 예약 시간입니다.")); + } + + private Theme findThemeById(Long id) { + return themeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 테마입니다.")); + } + + private void validateDuplicated(Reservation reservation) { + boolean isDuplicated = reservationRepository.existsBy(reservation.getDate(), reservation.getTimeId(), + reservation.getThemeId()); + if (isDuplicated) { + throw new IllegalTimeException("해당 시간대에 이미 예약된 테마입니다."); + } + } + + private void validateRequestedTime(Reservation reservation, ReservationTime reservationTime) { + LocalDateTime requestedDateTime = LocalDateTime.of(reservation.getDate(), reservationTime.getStartAt()); + if (requestedDateTime.isBefore(LocalDateTime.now())) { + throw new IllegalTimeException("이미 지난 날짜는 예약할 수 없습니다."); + } + } + + public List readReservations() { + return reservationRepository.findAll().stream() + .map(ReservationResponse::from) + .toList(); + } + + public ReservationResponse readReservation(Long id) { + Reservation reservation = reservationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 예약입니다.")); + return ReservationResponse.from(reservation); + } + + public void deleteReservation(Long id) { + reservationRepository.deleteById(id); + } +} diff --git a/src/main/java/roomescape/service/ReservationTimeService.java b/src/main/java/roomescape/service/ReservationTimeService.java new file mode 100644 index 0000000000..a25322d3a4 --- /dev/null +++ b/src/main/java/roomescape/service/ReservationTimeService.java @@ -0,0 +1,83 @@ +package roomescape.service; + +import org.springframework.stereotype.Service; +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.exception.IllegalTimeException; +import roomescape.exception.ReferencedReservationExistException; +import roomescape.service.dto.reservationtime.ReservationTimeCreateRequest; +import roomescape.service.dto.reservationtime.ReservationTimeResponse; +import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ReservationTimeService { + + private final ReservationTimeRepository reservationTimeRepository; + private final ReservationRepository reservationRepository; + + public ReservationTimeService(ReservationTimeRepository reservationTimeRepository, + ReservationRepository reservationRepository) { + this.reservationTimeRepository = reservationTimeRepository; + this.reservationRepository = reservationRepository; + } + + public ReservationTimeResponse createTime(ReservationTimeCreateRequest request) { + ReservationTime reservationTime = request.toReservationTime(); + + validateDuplicated(reservationTime); + + ReservationTime savedReservationTime = reservationTimeRepository.save(reservationTime); + return ReservationTimeResponse.from(savedReservationTime); + } + + private void validateDuplicated(ReservationTime reservationTime) { + List reservationTimes = reservationTimeRepository.findAll(); + boolean isDuplicated = reservationTimes.stream() + .anyMatch(reservationTime::isDuplicated); + if (isDuplicated) { + throw new IllegalTimeException("중복된 예약 시간입니다."); + } + } + + public List readReservationTimes() { + return reservationTimeRepository.findAll().stream() + .map(ReservationTimeResponse::from) + .toList(); + } + + public List readReservationTimes(LocalDate date, Long themeId) { + List reservations = reservationRepository.findByDateAndThemeId(date, themeId); + Set alreadyBookedTimes = reservations.stream() + .map(reservation -> reservation.getTime().getId()) + .collect(Collectors.toSet()); + + return reservationTimeRepository.findAll().stream() + .map(time -> createTimeResponse(time, alreadyBookedTimes)) + .toList(); + } + + private ReservationTimeResponse createTimeResponse(ReservationTime time, Set alreadyBookedTimes) { + boolean alreadyBooked = alreadyBookedTimes.contains(time.getId()); + return ReservationTimeResponse.of(time, alreadyBooked); + } + + public ReservationTimeResponse readReservationTime(Long id) { + ReservationTime reservationTime = reservationTimeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 예약 시간입니다.")); + return ReservationTimeResponse.from(reservationTime); + } + + public void deleteTime(Long id) { + if (reservationRepository.existsByTimeId(id)) { + throw new ReferencedReservationExistException("해당 시간대에 예약이 존재합니다."); + } + reservationTimeRepository.deleteById(id); + } +} diff --git a/src/main/java/roomescape/service/ThemeService.java b/src/main/java/roomescape/service/ThemeService.java new file mode 100644 index 0000000000..82344622d4 --- /dev/null +++ b/src/main/java/roomescape/service/ThemeService.java @@ -0,0 +1,83 @@ +package roomescape.service; + +import org.springframework.stereotype.Service; +import roomescape.domain.Reservation; +import roomescape.domain.Theme; +import roomescape.exception.DuplicatedException; +import roomescape.exception.ReferencedReservationExistException; +import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.theme.ThemeRepository; +import roomescape.service.dto.theme.ThemeCreateRequest; +import roomescape.service.dto.theme.ThemeResponse; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class ThemeService { + + public static final long POPULAR_THEME_PERIOD = 7L; + public static final long POPULAR_THEME_COUNT = 10L; + private final ThemeRepository themeRepository; + private final ReservationRepository reservationRepository; + + public ThemeService(ThemeRepository themeRepository, ReservationRepository reservationRepository) { + this.themeRepository = themeRepository; + this.reservationRepository = reservationRepository; + } + + public ThemeResponse createTheme(ThemeCreateRequest request) { + Theme theme = request.toTheme(); + validateDuplicated(theme); + Theme savedTheme = themeRepository.save(theme); + return ThemeResponse.from(savedTheme); + } + + private void validateDuplicated(Theme theme) { + boolean isDuplicatedName = themeRepository.findAll().stream() + .anyMatch(theme::isDuplicated); + if (isDuplicatedName) { + throw new DuplicatedException("중복된 테마 이름입니다."); + } + } + + public List readThemes() { + return themeRepository.findAll().stream() + .map(ThemeResponse::from) + .toList(); + } + + public List readPopularThemes() { + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(POPULAR_THEME_PERIOD); + + Map themeReferenceCount = calcThemeReferenceCount(start, end); + return themeReferenceCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(POPULAR_THEME_COUNT) + .map(e -> ThemeResponse.from(e.getKey())) + .toList(); + } + + private Map calcThemeReferenceCount(LocalDate start, LocalDate end) { + return reservationRepository.findByDateBetween(start, end).stream() + .collect(Collectors.groupingBy(Reservation::getTheme, Collectors.counting())); + } + + public ThemeResponse readTheme(Long id) { + Theme theme = themeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 테마입니다.")); + return ThemeResponse.from(theme); + } + + public void deleteTheme(Long id) { + if (reservationRepository.existsByThemeId(id)) { + throw new ReferencedReservationExistException("해당 테마에 예약이 존재합니다."); + } + themeRepository.deleteById(id); + } +} diff --git a/src/main/java/roomescape/service/dto/reservation/ReservationCreateRequest.java b/src/main/java/roomescape/service/dto/reservation/ReservationCreateRequest.java new file mode 100644 index 0000000000..f1f01cb089 --- /dev/null +++ b/src/main/java/roomescape/service/dto/reservation/ReservationCreateRequest.java @@ -0,0 +1,14 @@ +package roomescape.service.dto.reservation; + +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.domain.Theme; + +import java.time.LocalDate; + +public record ReservationCreateRequest(String name, LocalDate date, Long timeId, Long themeId) { + + public Reservation toReservation(ReservationTime reservationTime, Theme theme) { + return new Reservation(name, date, reservationTime, theme); + } +} diff --git a/src/main/java/roomescape/service/dto/reservation/ReservationResponse.java b/src/main/java/roomescape/service/dto/reservation/ReservationResponse.java new file mode 100644 index 0000000000..e10cd67f54 --- /dev/null +++ b/src/main/java/roomescape/service/dto/reservation/ReservationResponse.java @@ -0,0 +1,20 @@ +package roomescape.service.dto.reservation; + +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.domain.Theme; + +import java.time.LocalDate; + +public record ReservationResponse(Long id, String name, LocalDate date, ReservationTime time, Theme theme) { + + public static ReservationResponse from(Reservation reservation) { + return new ReservationResponse( + reservation.getId(), + reservation.getName(), + reservation.getDate(), + reservation.getTime(), + reservation.getTheme() + ); + } +} diff --git a/src/main/java/roomescape/service/dto/reservationtime/ReservationTimeCreateRequest.java b/src/main/java/roomescape/service/dto/reservationtime/ReservationTimeCreateRequest.java new file mode 100644 index 0000000000..690816826c --- /dev/null +++ b/src/main/java/roomescape/service/dto/reservationtime/ReservationTimeCreateRequest.java @@ -0,0 +1,12 @@ +package roomescape.service.dto.reservationtime; + +import roomescape.domain.ReservationTime; + +import java.time.LocalTime; + +public record ReservationTimeCreateRequest(LocalTime startAt) { + + public ReservationTime toReservationTime() { + return new ReservationTime(startAt); + } +} diff --git a/src/main/java/roomescape/service/dto/reservationtime/ReservationTimeResponse.java b/src/main/java/roomescape/service/dto/reservationtime/ReservationTimeResponse.java new file mode 100644 index 0000000000..9b66bb6cb2 --- /dev/null +++ b/src/main/java/roomescape/service/dto/reservationtime/ReservationTimeResponse.java @@ -0,0 +1,18 @@ +package roomescape.service.dto.reservationtime; + +import com.fasterxml.jackson.annotation.JsonInclude; +import roomescape.domain.ReservationTime; + +import java.time.LocalTime; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ReservationTimeResponse(Long id, LocalTime startAt, Boolean alreadyBooked) { + + public static ReservationTimeResponse from(ReservationTime reservationTime) { + return new ReservationTimeResponse(reservationTime.getId(), reservationTime.getStartAt(), null); + } + + public static ReservationTimeResponse of(ReservationTime reservationTime, Boolean alreadyBooked) { + return new ReservationTimeResponse(reservationTime.getId(), reservationTime.getStartAt(), alreadyBooked); + } +} diff --git a/src/main/java/roomescape/service/dto/theme/ThemeCreateRequest.java b/src/main/java/roomescape/service/dto/theme/ThemeCreateRequest.java new file mode 100644 index 0000000000..bf439f9830 --- /dev/null +++ b/src/main/java/roomescape/service/dto/theme/ThemeCreateRequest.java @@ -0,0 +1,10 @@ +package roomescape.service.dto.theme; + +import roomescape.domain.Theme; + +public record ThemeCreateRequest(String name, String description, String thumbnail) { + + public Theme toTheme() { + return new Theme(name, description, thumbnail); + } +} diff --git a/src/main/java/roomescape/service/dto/theme/ThemeResponse.java b/src/main/java/roomescape/service/dto/theme/ThemeResponse.java new file mode 100644 index 0000000000..a90abc4898 --- /dev/null +++ b/src/main/java/roomescape/service/dto/theme/ThemeResponse.java @@ -0,0 +1,20 @@ +package roomescape.service.dto.theme; + +import roomescape.domain.Theme; + +public record ThemeResponse( + Long id, + String name, + String description, + String thumbnail +) { + + public static ThemeResponse from(Theme theme) { + return new ThemeResponse( + theme.getId(), + theme.getName(), + theme.getDescription(), + theme.getThumbnail() + ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000000..3c0665609a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + h2: + console: + enabled: true + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:roomescape + username: sa diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000000..28018381ee --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,14 @@ +INSERT INTO reservation_time (start_at) VALUES ('10:00:00'); +INSERT INTO reservation_time (start_at) VALUES ('12:00:00'); +INSERT INTO theme (name, description, thumbnail) VALUES ( '공포', '완전 무서운 테마', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg' ); +INSERT INTO theme (name, description, thumbnail) VALUES ( '힐링', '완전 힐링되는 테마', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg' ); +INSERT INTO theme (name, description, thumbnail) VALUES ( '힐링2', '완전 힐링되는 테마2', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg' ); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '페드로', '2099-12-31', 1, 1); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버', '2099-12-31', 1, 2); + +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '페드로', '2024-4-28', 1, 1); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버', '2024-4-28', 1, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '페드로', '2024-4-30', 1, 1); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버', '2024-4-27', 1, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버', '2024-4-30', 1, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버', '2024-4-30', 1, 3); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000000..9440b55e4a --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS reservation_time +( + id BIGINT NOT NULL AUTO_INCREMENT, + start_at VARCHAR(255) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS theme +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + thumbnail VARCHAR(255) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS reservation +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + date VARCHAR(255) NOT NULL, + time_id BIGINT, + theme_id BIGINT, + PRIMARY KEY (id), + FOREIGN KEY (time_id) REFERENCES reservation_time (id), + FOREIGN KEY (theme_id) REFERENCES theme (id) +); diff --git a/src/main/resources/static/js/ranking.js b/src/main/resources/static/js/ranking.js index dee05edf0b..a5107a7265 100644 --- a/src/main/resources/static/js/ranking.js +++ b/src/main/resources/static/js/ranking.js @@ -1,8 +1,5 @@ document.addEventListener('DOMContentLoaded', () => { - /* - TODO: [3단계] 인기 테마 - 인기 테마 목록 조회 API 호출 - */ - requestRead('/') // 인기 테마 목록 조회 API endpoint + requestRead('/themes/popular') // 인기 테마 목록 조회 API endpoint .then(render) .catch(error => console.error('Error fetching times:', error)); }); @@ -10,14 +7,10 @@ document.addEventListener('DOMContentLoaded', () => { function render(data) { const container = document.getElementById('theme-ranking'); - /* - TODO: [3단계] 인기 테마 - 인기 테마 목록 조회 API 호출 후 렌더링 - response 명세에 맞춰 name, thumbnail, description 값 설정 - */ data.forEach(theme => { - const name = ''; - const thumbnail = ''; - const description = ''; + const name = theme.name; + const thumbnail = theme.thumbnail; + const description = theme.description; const htmlContent = ` ${name} diff --git a/src/main/resources/static/js/reservation-new.js b/src/main/resources/static/js/reservation-new.js index 098b8b70d8..c1a749c275 100644 --- a/src/main/resources/static/js/reservation-new.js +++ b/src/main/resources/static/js/reservation-new.js @@ -23,10 +23,6 @@ function render(data) { data.forEach(item => { const row = tableBody.insertRow(); - /* - TODO: [2단계] 관리자 기능 - 예약 목록 조회 API 호출 후 렌더링 - response 명세에 맞춰 값 설정 - */ row.insertCell(0).textContent = item.id; // 예약 id row.insertCell(1).textContent = item.name; // 예약자명 row.insertCell(2).textContent = item.theme.name; // 테마명 @@ -173,7 +169,7 @@ function requestCreate(reservation) { return fetch(RESERVATION_API_ENDPOINT, requestOptions) .then(response => { - if (response.status === 201) return response.json(); + if (response.status === 200) return response.json(); throw new Error('Create failed'); }); } @@ -185,7 +181,7 @@ function requestDelete(id) { return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions) .then(response => { - if (response.status !== 204) throw new Error('Delete failed'); + if (response.status !== 200) throw new Error('Delete failed'); }); } diff --git a/src/main/resources/static/js/time.js b/src/main/resources/static/js/time.js index 904602f9aa..c43dddd03d 100644 --- a/src/main/resources/static/js/time.js +++ b/src/main/resources/static/js/time.js @@ -109,7 +109,7 @@ function requestCreate(data) { return fetch(API_ENDPOINT, requestOptions) .then(response => { - if (response.status === 201) return response.json(); + if (response.status === 200) return response.json(); throw new Error('Create failed'); }); } @@ -129,6 +129,6 @@ function requestDelete(id) { return fetch(`${API_ENDPOINT}/${id}`, requestOptions) .then(response => { - if (response.status !== 204) throw new Error('Delete failed'); + if (response.status !== 200) throw new Error('Delete failed'); }); } diff --git a/src/main/resources/static/js/user-reservation.js b/src/main/resources/static/js/user-reservation.js index 89ff141af8..a94627208a 100644 --- a/src/main/resources/static/js/user-reservation.js +++ b/src/main/resources/static/js/user-reservation.js @@ -36,13 +36,9 @@ function renderTheme(themes) { const themeSlots = document.getElementById('theme-slots'); themeSlots.innerHTML = ''; themes.forEach(theme => { - const name = ''; - const themeId = ''; - /* - TODO: [3단계] 사용자 예약 - 테마 목록 조회 API 호출 후 렌더링 - response 명세에 맞춰 createSlot 함수 호출 시 값 설정 - createSlot('theme', theme name, theme id) 형태로 호출 - */ + const name = theme.name; + const themeId = theme.id; + createSlot('theme', name, themeId); themeSlots.appendChild(createSlot('theme', name, themeId)); }); } @@ -87,11 +83,7 @@ function checkDateAndTheme() { } function fetchAvailableTimes(date, themeId) { - /* - TODO: [3단계] 사용자 예약 - 예약 가능 시간 조회 API 호출 - 요청 포맷에 맞게 설정 - */ - fetch('/', { // 예약 가능 시간 조회 API endpoint + fetch(`/times?date=${date}&themeId=${themeId}`, { // 예약 가능 시간 조회 API endpoint method: 'GET', headers: { 'Content-Type': 'application/json', @@ -116,13 +108,9 @@ function renderAvailableTimes(times) { return; } times.forEach(time => { - /* - TODO: [3단계] 사용자 예약 - 예약 가능 시간 조회 API 호출 후 렌더링 - response 명세에 맞춰 createSlot 함수 호출 시 값 설정 - */ - const startAt = ''; - const timeId = ''; - const alreadyBooked = false; + const startAt = time.startAt; + const timeId = time.id; + const alreadyBooked = time.alreadyBooked; const div = createSlot('time', startAt, timeId, alreadyBooked); // createSlot('time', 시작 시간, time id, 예약 여부) timeSlots.appendChild(div); @@ -158,9 +146,8 @@ function onReservationButtonClick() { if (selectedDate && selectedThemeId && selectedTimeId) { /* - TODO: [3단계] 사용자 예약 - 예약 요청 API 호출 - [5단계] 예약 생성 기능 변경 - 사용자 - request 명세에 맞게 설정 + TODO: [5단계] 예약 생성 기능 변경 - 사용자 + request 명세에 맞게 설정 */ const reservationData = { date: selectedDate, diff --git a/src/test/java/roomescape/DataBaseConnectTest.java b/src/test/java/roomescape/DataBaseConnectTest.java new file mode 100644 index 0000000000..07338e4ee9 --- /dev/null +++ b/src/test/java/roomescape/DataBaseConnectTest.java @@ -0,0 +1,38 @@ +package roomescape; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Connection; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +@JdbcTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("데이터베이스") +class DataBaseConnectTest { + + private final JdbcTemplate jdbcTemplate; + + @Autowired + public DataBaseConnectTest(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @DisplayName("데이터베이스와 연결한다.") + @Test + void connectToDatabase() { + try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { + assertThat(connection).isNotNull(); + assertThat(connection.getCatalog()).isEqualTo("ROOMESCAPE"); + assertThat(connection.getMetaData().getTables(null, null, "RESERVATION", null).next()).isTrue(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/roomescape/Fixtures.java b/src/test/java/roomescape/Fixtures.java new file mode 100644 index 0000000000..0889bf0717 --- /dev/null +++ b/src/test/java/roomescape/Fixtures.java @@ -0,0 +1,75 @@ +package roomescape; + +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.domain.Theme; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public class Fixtures { + public static final LocalDate DATE_AFTER_6_MONTH_LATER = LocalDate.now().plusMonths(6); + + public static final LocalTime TIME_10_10 = LocalTime.of(10, 10); + + public static final ReservationTime reservationTimeFixture = new ReservationTime( + 1L, + LocalTime.of(10, 10) + ); + + public static final Theme themeFixture = new Theme( + 1L, + "공포", + "완전 무서운 테마", + "https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg" + ); + + public static final Reservation reservationFixture = new Reservation( + 1L, "클로버", LocalDate.now().plusMonths(6), + reservationTimeFixture, themeFixture + ); + + public static final List themeFixtures = List.of( + new Theme(1L, "테마 이름 1", "테마 설명 1", "1"), + new Theme(2L, "테마 이름 2", "테마 설명 2", "2"), + new Theme(3L, "테마 이름 3", "테마 설명 3", "3"), + new Theme(4L, "테마 이름 4", "테마 설명 4", "4"), + new Theme(5L, "테마 이름 5", "테마 설명 5", "5"), + new Theme(6L, "테마 이름 6", "테마 설명 6", "6"), + new Theme(7L, "테마 이름 7", "테마 설명 7", "7"), + new Theme(8L, "테마 이름 8", "테마 설명 8", "8"), + new Theme(9L, "테마 이름 9", "테마 설명 9", "9"), + new Theme(10L, "테마 이름 10", "테마 설명 10", "10"), + new Theme(11L, "테마 이름 11", "테마 설명 11", "11") + ); + + // 5번 테마(7회), 1번 테마(5회), 2번 테마(4회), 3번 테마(3회), 4번 테마(1회) + public static final List reservationFixturesForPopularTheme = List.of( + // 1번 테마 + new Reservation(1L, "예약자명1", LocalDate.now().minusDays(3), null, themeFixtures.get(0)), + new Reservation(2L, "예약자명2", LocalDate.now().minusDays(3), null, themeFixtures.get(0)), + new Reservation(3L, "예약자명3", LocalDate.now().minusDays(3), null, themeFixtures.get(0)), + new Reservation(4L, "예약자명4", LocalDate.now().minusDays(3), null, themeFixtures.get(0)), + new Reservation(5L, "예약자명5", LocalDate.now().minusDays(3), null, themeFixtures.get(0)), + // 2번 테마 + new Reservation(6L, "예약자명6", LocalDate.now().minusDays(3), null, themeFixtures.get(1)), + new Reservation(7L, "예약자명7", LocalDate.now().minusDays(3), null, themeFixtures.get(1)), + new Reservation(8L, "예약자명8", LocalDate.now().minusDays(3), null, themeFixtures.get(1)), + new Reservation(9L, "예약자명9", LocalDate.now().minusDays(3), null, themeFixtures.get(1)), + // 3번 테마 + new Reservation(10L, "예약자명10", LocalDate.now().minusDays(3), null, themeFixtures.get(2)), + new Reservation(11L, "예약자명11", LocalDate.now().minusDays(3), null, themeFixtures.get(2)), + new Reservation(12L, "예약자명12", LocalDate.now().minusDays(3), null, themeFixtures.get(2)), + // 4번 테마 + new Reservation(13L, "예약자명13", LocalDate.now().minusDays(3), null, themeFixtures.get(3)), + // 5번 테마 + new Reservation(14L, "예약자명14", LocalDate.now().minusDays(3), null, themeFixtures.get(4)), + new Reservation(15L, "예약자명15", LocalDate.now().minusDays(3), null, themeFixtures.get(4)), + new Reservation(16L, "예약자명16", LocalDate.now().minusDays(3), null, themeFixtures.get(4)), + new Reservation(17L, "예약자명17", LocalDate.now().minusDays(3), null, themeFixtures.get(4)), + new Reservation(18L, "예약자명18", LocalDate.now().minusDays(3), null, themeFixtures.get(4)), + new Reservation(19L, "예약자명19", LocalDate.now().minusDays(3), null, themeFixtures.get(4)), + new Reservation(20L, "예약자명20", LocalDate.now().minusDays(3), null, themeFixtures.get(4)) + ); +} diff --git a/src/test/java/roomescape/RoomescapeApplicationTest.java b/src/test/java/roomescape/RoomescapeApplicationTest.java deleted file mode 100644 index 326a3ff677..0000000000 --- a/src/test/java/roomescape/RoomescapeApplicationTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package roomescape; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class RoomescapeApplicationTest { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/roomescape/controller/AdminControllerTest.java b/src/test/java/roomescape/controller/AdminControllerTest.java new file mode 100644 index 0000000000..cebd5afe26 --- /dev/null +++ b/src/test/java/roomescape/controller/AdminControllerTest.java @@ -0,0 +1,57 @@ +package roomescape.controller; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("어드민 컨트롤러") +class AdminControllerTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @DisplayName("어드민 컨트롤러는 /admin으로 GET 요청이 들어오면 어드민 페이지를 반환한다.") + @Test + void readAdminPage() { + RestAssured.given().log().all() + .when().get("/admin") + .then().log().all() + .statusCode(200); + } + + @DisplayName("어드민 컨트롤러는 /admin/reservation으로 GET 요청이 들어오면 예약 목록 페이지를 반환한다.") + @Test + void readReservations() { + RestAssured.given().log().all() + .when().get("/admin/reservation") + .then().log().all() + .statusCode(200); + } + + @DisplayName("어드민 컨트롤러는 /admin/time으로 GET 요청이 들어오면 시간 목록 페이지를 반환한다.") + @Test + void readTimes() { + RestAssured.given().log().all() + .when().get("/admin/time") + .then().log().all() + .statusCode(200); + } + + @DisplayName("어드민 컨트롤러는 /admin/theme로 GET 요청이 들어오면 테마 목록 페이지를 반환한다.") + @Test + void readTheme() { + RestAssured.given().log().all() + .when().get("/admin/theme") + .then().log().all() + .statusCode(200); + } +} diff --git a/src/test/java/roomescape/controller/ReservationControllerTest.java b/src/test/java/roomescape/controller/ReservationControllerTest.java new file mode 100644 index 0000000000..185e8875ae --- /dev/null +++ b/src/test/java/roomescape/controller/ReservationControllerTest.java @@ -0,0 +1,120 @@ +package roomescape.controller; + +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +@Sql(value = {"/recreate_table.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("예약 컨트롤러") +class ReservationControllerTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @DisplayName("예약 컨트롤러는 예약 조회 시 값을 반환한다.") + @Test + void readReservations() { + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(7)); + } + + @DisplayName("예약 컨트롤러는 예약 생성 시 생성된 값을 반환한다.") + @Test + void createReservation() { + Map reservation = new HashMap<>(); + reservation.put("name", "브라운"); + reservation.put("date", LocalDate.MAX.toString()); + reservation.put("timeId", 1); + reservation.put("themeId", 1); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(reservation) + .when().post("/reservations") + .then().log().all() + .statusCode(200); + + RestAssured.given() + .contentType(ContentType.JSON) + .when().get("/reservations") + .then() + .statusCode(200) + .body("size()", is(8)); + } + + @DisplayName("예약 컨트롤러는 잘못된 형식의 날짜로 예약 생성 요청 시 400을 응답한다.") + @ValueSource(strings = {"Hello", "2024-13-20", "2900-12-32"}) + @ParameterizedTest + void createInvalidDateReservation(String invalidString) { + Map reservation = new HashMap<>(); + reservation.put("name", "브라운"); + reservation.put("date", invalidString); + reservation.put("timeId", 1); + + String detailMessage = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(reservation) + .when().post("/reservations") + .then().log().all() + .statusCode(400) + .extract() + .jsonPath().get("detail"); + + assertThat(detailMessage).isEqualTo("잘못된 형식의 날짜 혹은 시간입니다."); + } + + @DisplayName("예약 컨트롤러는 예약 생성 시 잘못된 형식의 본문이 들어오면 400을 응답한다.") + @Test + void createInvalidRequestBody() { + String invalidBody = "invalidBody"; + + String detailMessage = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(invalidBody) + .when().post("/reservations") + .then().log().all() + .statusCode(400) + .extract() + .jsonPath().get("detail"); + + assertThat(detailMessage).isEqualTo("요청에 잘못된 형식의 값이 있습니다."); + } + + @DisplayName("예약 컨트롤러는 id 값에 따라 예약을 삭제한다.") + @Test + void deleteReservation() { + RestAssured.given().log().all() + .when().delete("/reservations/1") + .then().log().all() + .statusCode(200); + + RestAssured.given() + .when().get("/reservations") + .then() + .statusCode(200) + .body("size()", is(6)); + } +} diff --git a/src/test/java/roomescape/controller/ReservationTimeControllerTest.java b/src/test/java/roomescape/controller/ReservationTimeControllerTest.java new file mode 100644 index 0000000000..27b4295671 --- /dev/null +++ b/src/test/java/roomescape/controller/ReservationTimeControllerTest.java @@ -0,0 +1,127 @@ +package roomescape.controller; + +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +@Sql(value = {"/recreate_table.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("시간 컨트롤러") +class ReservationTimeControllerTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @DisplayName("시간 컨트롤러는 시간 추가 요청이 들어오면 저장 후 200을 반환한다.") + @Test + void createTime() { + Map params = new HashMap<>(); + params.put("startAt", "10:30"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/times") + .then().log().all() + .statusCode(200); + + RestAssured.given() + .contentType(ContentType.JSON) + .when().get("/times") + .then() + .statusCode(200) + .body("size()", is(4)); + } + + @DisplayName("시간 컨트롤러는 시간 생성 시 잘못된 형식의 본문이 들어오면 400을 응답한다.") + @Test + void createInvalidRequestBody() { + String invalidBody = "invalidBody"; + + String detailMessage = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(invalidBody) + .when().post("/times") + .then().log().all() + .statusCode(400) + .extract() + .jsonPath().get("detail"); + + assertThat(detailMessage).isEqualTo("요청에 잘못된 형식의 값이 있습니다."); + } + + @DisplayName("시간 컨트롤러는 잘못된 형식의 시간으로 시간 생성 요청 시 400을 응답한다.") + @ValueSource(strings = {"aaa", "10:000", "25:30"}) + @ParameterizedTest + void createInvalidTimeReservationTime(String invalidTime) { + Map params = new HashMap<>(); + params.put("startAt", invalidTime); + + String detailMessage = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/times") + .then().log().all() + .statusCode(400) + .extract() + .jsonPath().get("detail"); + + assertThat(detailMessage).isEqualTo("잘못된 형식의 날짜 혹은 시간입니다."); + } + + @DisplayName("시간 컨트롤러는 시간 조회 요청이 들어오면 저장된 시간을 반환한다.") + @Test + void readTimes() { + RestAssured.given().log().all() + .when().get("/times") + .then().log().all() + .statusCode(200) + .body("size()", is(3)); + } + + @DisplayName("시간 컨트롤러는 특정 날짜와 테마에 대한 시간 조회 요청이 들어오면 저장된 시간을 반환한다.") + @Test + void readTimesWithDateAndThemeId() { + RestAssured.given().log().all() + .queryParam("date", "2024-12-02") + .queryParam("themeId", 2) + .when().get("/times") + .then().log().all() + .statusCode(200) + .body("size()", is(3)); + } + + @DisplayName("시간 컨트롤러는 시간 삭제 요청이 들어오면 삭제 후 200을 반환한다.") + @Test + void deleteTime() { + RestAssured.given().log().all() + .when().delete("/times/3") + .then().log().all() + .statusCode(200); + + RestAssured.given() + .contentType(ContentType.JSON) + .when().get("/times") + .then() + .statusCode(200) + .body("size()", is(2)); + } +} diff --git a/src/test/java/roomescape/controller/ThemeControllerTest.java b/src/test/java/roomescape/controller/ThemeControllerTest.java new file mode 100644 index 0000000000..5855f5e095 --- /dev/null +++ b/src/test/java/roomescape/controller/ThemeControllerTest.java @@ -0,0 +1,76 @@ +package roomescape.controller; + +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.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.jdbc.Sql; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +@Sql(value = {"/recreate_table.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("테마 컨트롤러") +class ThemeControllerTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @DisplayName("테마 컨트롤러는 테마 추가 요청이 들어오면 저장 후 201을 반환한다.") + @Test + void createTheme() { + Map params = new HashMap<>(); + params.put("name", "리얼공포"); + params.put("description", "완전 무서움"); + params.put("thumbnail", "http://something"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/themes") + .then().log().all() + .statusCode(201); + } + + @DisplayName("테마 컨트롤러는 테마 조회 요청이 들어오면 200을 반환한다.") + @Test + void readThemes() { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/themes") + .then().log().all() + .statusCode(200) + .body("size()", is(3)); + } + + @DisplayName("테마 컨트롤러는 인기 테마 조회 요청이 들어오면 200을 반환한다.") + @Test + void readPopularThemes() { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/themes/popular") + .then().log().all() + .statusCode(200); + } + + @DisplayName("테마 컨트롤러는 테마 삭제 요청이 들어오면 204를 반환한다.") + @Test + void deleteTheme() { + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().delete("/themes/3") + .then().log().all() + .statusCode(204); + } +} diff --git a/src/test/java/roomescape/domain/ReservationTest.java b/src/test/java/roomescape/domain/ReservationTest.java new file mode 100644 index 0000000000..198807d0b3 --- /dev/null +++ b/src/test/java/roomescape/domain/ReservationTest.java @@ -0,0 +1,25 @@ +package roomescape.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.LocalDate; +import java.time.LocalTime; +import roomescape.exception.EmptyParameterException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static roomescape.Fixtures.themeFixture; + +@DisplayName("예약") +class ReservationTest { + + @DisplayName("예약자명이 공백인 경우 예외가 발생한다.") + @ValueSource(strings = {"", " ", " ", "\n", "\r", "\t"}) + @ParameterizedTest + void validateName(String blankName) { + ReservationTime time = new ReservationTime(LocalTime.MAX); + assertThatThrownBy(() -> new Reservation(blankName, LocalDate.MAX, time, themeFixture)) + .isInstanceOf(EmptyParameterException.class) + .hasMessage("이름은 공백일 수 없습니다."); + } +} diff --git a/src/test/java/roomescape/domain/ThemeTest.java b/src/test/java/roomescape/domain/ThemeTest.java new file mode 100644 index 0000000000..b5da748e0f --- /dev/null +++ b/src/test/java/roomescape/domain/ThemeTest.java @@ -0,0 +1,31 @@ +package roomescape.domain; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import roomescape.exception.EmptyParameterException; + +@DisplayName("테마") +class ThemeTest { + + @DisplayName("테마 생성 시 이름, 설명, 썸네일 중 하나라도 비어있을 경우 예외가 발생한다.") + @ValueSource(strings = {"", " ", " ", "\n", "\r", "\t"}) + @ParameterizedTest + void validateNotBlank(String blank) { + // when & then + String expectedMessage = "테마의 정보는 비어있을 수 없습니다."; + + SoftAssertions softAssertions = new SoftAssertions(); + softAssertions.assertThatThrownBy(() -> new Theme(blank, "description", "thumbnail")) + .isInstanceOf(EmptyParameterException.class) + .hasMessage(expectedMessage); + softAssertions.assertThatThrownBy(() -> new Theme("name", blank, "thumbnail")) + .isInstanceOf(EmptyParameterException.class) + .hasMessage(expectedMessage); + softAssertions.assertThatThrownBy(() -> new Theme("name", "description", blank)) + .isInstanceOf(EmptyParameterException.class) + .hasMessage(expectedMessage); + softAssertions.assertAll(); + } +} diff --git a/src/test/java/roomescape/repository/ReservationDaoTest.java b/src/test/java/roomescape/repository/ReservationDaoTest.java new file mode 100644 index 0000000000..b03f866fed --- /dev/null +++ b/src/test/java/roomescape/repository/ReservationDaoTest.java @@ -0,0 +1,127 @@ +package roomescape.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import org.springframework.test.context.jdbc.Sql; +import roomescape.Fixtures; +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; +import roomescape.domain.Theme; +import roomescape.repository.reservation.ReservationDao; +import roomescape.repository.reservation.ReservationRepository; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static roomescape.Fixtures.themeFixture; + +@JdbcTest +@Import(ReservationDao.class) +@DisplayName("예약 DAO") +@Sql(value = {"/recreate_table.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class ReservationDaoTest { + + private final ReservationRepository reservationRepository; + private final SimpleJdbcInsert simpleJdbcInsertWithReservationTime; + + @Autowired + public ReservationDaoTest(ReservationRepository reservationRepository, DataSource dataSource) { + this.reservationRepository = reservationRepository; + this.simpleJdbcInsertWithReservationTime = new SimpleJdbcInsert(dataSource) + .withTableName("reservation_time") + .usingGeneratedKeyColumns("id"); + } + + @DisplayName("예약 DAO는 생성 요청이 들어오면 DB에 값을 저장한다.") + @Test + void save() { + // given + ReservationTime reservationTime = Fixtures.reservationTimeFixture; + Long reservationTimeId = simpleJdbcInsertWithReservationTime.executeAndReturnKey(new BeanPropertySqlParameterSource(reservationTime)) + .longValue(); + ReservationTime newReservationTime = new ReservationTime(reservationTimeId, reservationTime.getStartAt()); + Reservation reservation = new Reservation( + "브라운", + LocalDate.of(2024, 11, 16), + newReservationTime, + new Theme(1L, themeFixture) + ); + + // when + Reservation newReservation = reservationRepository.save(reservation); + Optional actual = reservationRepository.findById(newReservation.getId()); + + // then + assertThat(actual).isPresent(); + } + + @DisplayName("예약 DAO는 조회 요청이 들어오면 id에 맞는 값을 반환한다.") + @Test + void findById() { + // when + Optional actual = reservationRepository.findById(1L); + + // then + assertThat(actual).isPresent(); + } + + @DisplayName("예약 DAO는 조회 요청이 들어오면 저장한 모든 값을 반환한다.") + @Test + void findAll() { + // when + List reservations = reservationRepository.findAll(); + + // then + assertThat(reservations).hasSize(7); + } + + @DisplayName("예약 DAO는 주어진 기간 동안의 모든 예약을 반환한다.") + @Test + void findByDateBetween() { + // given + LocalDate startDate = LocalDate.of(2024, 12, 1); + LocalDate endDate = LocalDate.of(2024, 12, 8); + + // when + List reservations = reservationRepository.findByDateBetween(startDate, endDate); + + // then + assertThat(reservations).hasSize(5); + } + + @DisplayName("예약 DAO는 주어진 날짜와 테마에 맞는 예약을 반환한다.") + @Test + void findByDateAndThemeId() { + // given + LocalDate date = LocalDate.of(2024, 12, 2); + Long themeId = 2L; + + // when + List reservations = reservationRepository.findByDateAndThemeId(date, themeId); + + // then + assertThat(reservations).hasSize(2); + } + + @DisplayName("예약 DAO는 삭제 요청이 들어오면 id에 맞는 값을 삭제한다.") + @Test + void deleteById() { + // given + Long id = 1L; + + // when + reservationRepository.deleteById(id); + Optional actual = reservationRepository.findById(id); + + // then + assertThat(actual).isNotPresent(); + } +} diff --git a/src/test/java/roomescape/repository/ReservationTimeDaoTest.java b/src/test/java/roomescape/repository/ReservationTimeDaoTest.java new file mode 100644 index 0000000000..7eb626000a --- /dev/null +++ b/src/test/java/roomescape/repository/ReservationTimeDaoTest.java @@ -0,0 +1,80 @@ +package roomescape.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.jdbc.core.JdbcTemplate; +import roomescape.Fixtures; +import roomescape.domain.ReservationTime; +import roomescape.repository.reservationtime.ReservationTimeDao; +import roomescape.repository.reservationtime.ReservationTimeRepository; + +import javax.sql.DataSource; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@JdbcTest +@DisplayName("예약 시간 DAO") +class ReservationTimeDaoTest { + + private final ReservationTimeRepository reservationTimeRepository; + + @Autowired + public ReservationTimeDaoTest(JdbcTemplate jdbcTemplate, DataSource dataSource) { + this.reservationTimeRepository = new ReservationTimeDao(jdbcTemplate, dataSource); + } + + @DisplayName("예약 시간 DAO는 생성 요청이 들어오면 DB에 값을 저장한다.") + @Test + void save() { + // given + ReservationTime reservationTime = Fixtures.reservationTimeFixture; + + // when + ReservationTime newReservationTime = reservationTimeRepository.save(reservationTime); + Optional actual = reservationTimeRepository.findById(newReservationTime.getId()); + + // then + assertThat(actual).isPresent(); + } + + @DisplayName("예약 시간 DAO는 조회 요청이 들어오면 id에 맞는 값을 반환한다.") + @Test + void findById() { + // given + Long id = 1L; + + // when + Optional reservationTime = reservationTimeRepository.findById(id); + + // then + assertThat(reservationTime).isPresent(); + } + + @DisplayName("예약 시간 DAO는 조회 요청이 들어오면 저장된 모든 값을 반환한다.") + @Test + void findAll() { + // when + List reservationTimes = reservationTimeRepository.findAll(); + + // then + assertThat(reservationTimes).hasSize(2); + } + + @DisplayName("예약 시간 DAO는 삭제 요청이 들어오면 id에 맞는 값을 삭제한다.") + @Test + void deleteById() { + // given + Long id = 3L; + + // when + reservationTimeRepository.deleteById(id); + Optional actual = reservationTimeRepository.findById(id); + + // then + assertThat(actual).isNotPresent(); + } +} diff --git a/src/test/java/roomescape/repository/ThemeDaoTest.java b/src/test/java/roomescape/repository/ThemeDaoTest.java new file mode 100644 index 0000000000..eadad3c615 --- /dev/null +++ b/src/test/java/roomescape/repository/ThemeDaoTest.java @@ -0,0 +1,68 @@ +package roomescape.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.jdbc.Sql; +import roomescape.Fixtures; +import roomescape.domain.Theme; +import roomescape.repository.theme.ThemeDao; +import roomescape.repository.theme.ThemeRepository; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("테마 DAO") +@JdbcTest +@Sql(value = {"/recreate_table.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class ThemeDaoTest { + + private final ThemeRepository themeRepository; + + @Autowired + public ThemeDaoTest(JdbcTemplate jdbcTemplate) { + this.themeRepository = new ThemeDao(jdbcTemplate); + } + + @DisplayName("테마 DAO는 생성 요청이 들어오면 DB에 값을 저장한다.") + @Test + void save() { + // given + Theme theme = Fixtures.themeFixture; + + // when + Theme savedTheme = themeRepository.save(theme); + Optional actual = themeRepository.findById(savedTheme.getId()); + + // then + assertThat(actual).isPresent(); + } + + @DisplayName("테마 DAO는 조회 요청이 들어오면 DB에 저장된 모든 테마를 반환한다.") + @Test + void findAll() { + // when + List themes = themeRepository.findAll(); + + // then + assertThat(themes).hasSize(3); + } + + @DisplayName("테마 DAO는 삭제 요청이 들어오면 id에 맞는 값을 삭제한다.") + @Test + void delete() { + // given + Long id = 3L; + + // when + themeRepository.deleteById(id); + Optional actual = themeRepository.findById(id); + + // then + assertThat(actual).isNotPresent(); + } +} diff --git a/src/test/java/roomescape/service/ReservationServiceTest.java b/src/test/java/roomescape/service/ReservationServiceTest.java new file mode 100644 index 0000000000..b16d68945b --- /dev/null +++ b/src/test/java/roomescape/service/ReservationServiceTest.java @@ -0,0 +1,188 @@ +package roomescape.service; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import roomescape.Fixtures; +import roomescape.exception.BadRequestException; +import roomescape.exception.IllegalTimeException; +import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; +import roomescape.repository.theme.ThemeRepository; +import roomescape.service.dto.reservation.ReservationCreateRequest; +import roomescape.service.dto.reservation.ReservationResponse; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +@DisplayName("예약 서비스") +class ReservationServiceTest { + @InjectMocks + private ReservationService reservationService; + @Mock + private ReservationTimeRepository reservationTimeRepository; + @Mock + private ReservationRepository reservationRepository; + @Mock + private ThemeRepository themeRepository; + + + @DisplayName("예약 서비스는 예약들을 조회한다.") + @Test + void readReservations() { + // given + Mockito.when(reservationRepository.findAll()) + .thenReturn(List.of(Fixtures.reservationFixture)); + + // when + List reservations = reservationService.readReservations(); + + // then + assertThat(reservations).hasSize(1); + } + + @DisplayName("예약 서비스는 id에 맞는 예약을 조회한다.") + @Test + void readReservation() { + // given + Mockito.when(reservationRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.reservationFixture)); + + // when + ReservationResponse reservation = reservationService.readReservation(1L); + + // then + SoftAssertions softAssertions = new SoftAssertions(); + softAssertions.assertThat(reservation.date()).isEqualTo(Fixtures.DATE_AFTER_6_MONTH_LATER); + softAssertions.assertThat(reservation.name()).isEqualTo("클로버"); + softAssertions.assertAll(); + } + + @DisplayName("예약 서비스는 예약을 생성한다.") + @Test + void createReservation() { + // given + Mockito.when(reservationTimeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.reservationTimeFixture)); + Mockito.when(themeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.themeFixture)); + ReservationCreateRequest request = new ReservationCreateRequest( + "클로버", Fixtures.DATE_AFTER_6_MONTH_LATER, 1L, 1L + ); + Mockito.when(reservationRepository.save(any())) + .thenReturn(Fixtures.reservationFixture); + + // when + ReservationResponse reservation = reservationService.createReservation(request); + + // then + SoftAssertions softAssertions = new SoftAssertions(); + softAssertions.assertThat(reservation.date()).isEqualTo(Fixtures.DATE_AFTER_6_MONTH_LATER); + softAssertions.assertThat(reservation.name()).isEqualTo("클로버"); + softAssertions.assertThat(reservation.time().getStartAt()).isEqualTo(LocalTime.of(10, 10)); + softAssertions.assertAll(); + } + + @DisplayName("예약 서비스는 지난 시점의 예약이 요청되면 예외가 발생한다.") + @Test + void validateRequestedTime() { + // given + Mockito.when(reservationTimeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.reservationTimeFixture)); + Mockito.when(themeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.themeFixture)); + + LocalDate date = LocalDate.MIN; + ReservationCreateRequest request = new ReservationCreateRequest( + "클로버", date, 1L, 1L + ); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(IllegalTimeException.class) + .hasMessage("이미 지난 날짜는 예약할 수 없습니다."); + } + + @DisplayName("예약 서비스는 중복된 예약 요청이 들어오면 예외가 발생한다.") + @Test + void validateIsDuplicated() { + // given + Mockito.when(reservationTimeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.reservationTimeFixture)); + Mockito.when(themeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.themeFixture)); + Mockito.when(reservationRepository.existsBy(any(), any(), any())) + .thenReturn(true); + ReservationCreateRequest request = new ReservationCreateRequest( + Fixtures.reservationFixture.getName(), + Fixtures.reservationFixture.getDate(), + 1L, + 1L + ); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("해당 시간대에 이미 예약된 테마입니다."); + } + + @DisplayName("예약 서비스는 예약 요청에 존재하지 않는 시간이 포함된 경우 예외가 발생한다.") + @Test + void createWithNonExistentTime() { + // given + Mockito.when(reservationTimeRepository.findById(1L)) + .thenReturn(Optional.empty()); + + ReservationCreateRequest request = new ReservationCreateRequest( + "클로버", Fixtures.DATE_AFTER_6_MONTH_LATER, 1L, 1L + ); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessage("존재하지 않는 예약 시간입니다."); + } + + @DisplayName("예약 서비스는 요청받은 테마가 동시간대에 이미 예약된 경우 예외가 발생한다.") + @Test + void createWithReservedTheme() { + // given + Mockito.when(reservationTimeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.reservationTimeFixture)); + Mockito.when(themeRepository.findById(1L)) + .thenReturn(Optional.of(Fixtures.themeFixture)); + Mockito.when(reservationRepository.existsBy(any(), any(), any())) + .thenReturn(true); + ReservationCreateRequest request = new ReservationCreateRequest( + "페드로", Fixtures.DATE_AFTER_6_MONTH_LATER, 1L, 1L + ); + + // when & then + assertThatThrownBy(() -> reservationService.createReservation(request)) + .isInstanceOf(IllegalTimeException.class) + .hasMessage("해당 시간대에 이미 예약된 테마입니다."); + } + + @DisplayName("예약 서비스는 id에 맞는 예약을 삭제한다.") + @Test + void deleteReservation() { + // given + Mockito.doNothing().when(reservationRepository).deleteById(1L); + + // when & then + assertThatCode(() -> reservationService.deleteReservation(1L)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/roomescape/service/ReservationTimeServiceTest.java b/src/test/java/roomescape/service/ReservationTimeServiceTest.java new file mode 100644 index 0000000000..f5f7e6a54c --- /dev/null +++ b/src/test/java/roomescape/service/ReservationTimeServiceTest.java @@ -0,0 +1,145 @@ +package roomescape.service; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import roomescape.Fixtures; +import roomescape.domain.Reservation; +import roomescape.exception.IllegalTimeException; +import roomescape.exception.ReferencedReservationExistException; +import roomescape.service.dto.reservationtime.ReservationTimeCreateRequest; +import roomescape.service.dto.reservationtime.ReservationTimeResponse; +import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +@DisplayName("예약 시간 서비스") +class ReservationTimeServiceTest { + @InjectMocks + private ReservationTimeService reservationTimeService; + @Mock + private ReservationTimeRepository reservationTimeRepository; + @Mock + private ReservationRepository reservationRepository; + + @DisplayName("에약 시간 서비스는 시간을 생성한다.") + @Test + void createTime() { + // given + Mockito.when(reservationTimeRepository.save(any())) + .thenReturn(Fixtures.reservationTimeFixture); + ReservationTimeCreateRequest request = new ReservationTimeCreateRequest(Fixtures.TIME_10_10); + + // when + ReservationTimeResponse reservationTime = reservationTimeService.createTime(request); + + // then + assertThat(reservationTime.startAt()).isEqualTo(Fixtures.TIME_10_10); + } + + @DisplayName("예약 시간 서비스는 중복된 예약 시간 요청이 들어오면 예외가 발생한다.") + @Test + void validateIsDuplicated() { + // given + Mockito.when(reservationTimeRepository.findAll()) + .thenReturn(List.of(Fixtures.reservationTimeFixture)); + ReservationTimeCreateRequest request = new ReservationTimeCreateRequest( + Fixtures.reservationTimeFixture.getStartAt()); + + // when & then + assertThatThrownBy(() -> reservationTimeService.createTime(request)) + .isInstanceOf(IllegalTimeException.class) + .hasMessage("중복된 예약 시간입니다."); + } + + @DisplayName("예약 시간 서비스는 id에 맞는 시간을 반환한다.") + @Test + void readReservationTime() { + // given + Long id = 1L; + Mockito.when(reservationTimeRepository.findById(id)) + .thenReturn(Optional.of(Fixtures.reservationTimeFixture)); + + // when + ReservationTimeResponse reservationTime = reservationTimeService.readReservationTime(id); + + // then + assertThat(reservationTime.startAt()).isEqualTo(Fixtures.TIME_10_10); + } + + @DisplayName("예약 시간 서비스는 시간들을 반환한다.") + @Test + void readReservationTimes() { + // given + Mockito.when(reservationTimeRepository.findAll()) + .thenReturn(List.of(Fixtures.reservationTimeFixture)); + + // when + List reservationTimes = reservationTimeService.readReservationTimes( + Fixtures.DATE_AFTER_6_MONTH_LATER, 1L + ); + + // then + assertThat(reservationTimes).hasSize(1); + } + + @DisplayName("예약 시간 서비스는 지정된 날짜와 테마별 예약 가능 여부를 포함하여 시간들을 반환한다.") + @Test + void readReservationTimesByDateAndThemeId() { + // given + LocalDate date = Fixtures.DATE_AFTER_6_MONTH_LATER; + Long themeId = 1L; + Mockito.when(reservationRepository.findByDateAndThemeId(date, themeId)) + .thenReturn(List.of(new Reservation("클로버", date, Fixtures.reservationTimeFixture, Fixtures.themeFixture))); + Mockito.when(reservationTimeRepository.findAll()) + .thenReturn(List.of(Fixtures.reservationTimeFixture)); + + // when + List reservationTimes = reservationTimeService.readReservationTimes(date, themeId); + + // then + SoftAssertions softAssertions = new SoftAssertions(); + softAssertions.assertThat(reservationTimes).hasSize(1); + softAssertions.assertThat(reservationTimes).contains(ReservationTimeResponse.of(Fixtures.reservationTimeFixture, true)); + softAssertions.assertAll(); + } + + @DisplayName("예약 시간 서비스는 id에 맞는 시간을 삭제한다.") + @Test + void deleteTime() { + // given + Long id = 1L; + Mockito.when(reservationRepository.existsByTimeId(id)) + .thenReturn(false); + Mockito.doNothing().when(reservationTimeRepository).deleteById(id); + + // when & then + assertThatCode(() -> reservationTimeService.deleteTime(id)) + .doesNotThrowAnyException(); + } + + @DisplayName("예약 시간 서비스는 시간 정보를 삭제할 때 id에 맞는 시간에 예약이 존재하면 예외가 발생한다.") + @Test + void deleteTimeWithExistsReservation() { + // given + Long id = 1L; + Mockito.when(reservationRepository.existsByTimeId(id)) + .thenReturn(true); + + // when & then + assertThatThrownBy(() -> reservationTimeService.deleteTime(id)) + .isInstanceOf(ReferencedReservationExistException.class); + } +} diff --git a/src/test/java/roomescape/service/ThemeServiceTest.java b/src/test/java/roomescape/service/ThemeServiceTest.java new file mode 100644 index 0000000000..39b49bac6a --- /dev/null +++ b/src/test/java/roomescape/service/ThemeServiceTest.java @@ -0,0 +1,134 @@ +package roomescape.service; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import roomescape.Fixtures; +import roomescape.exception.DuplicatedException; +import roomescape.exception.ReferencedReservationExistException; +import roomescape.service.dto.theme.ThemeCreateRequest; +import roomescape.service.dto.theme.ThemeResponse; +import roomescape.repository.theme.ThemeRepository; +import roomescape.repository.reservation.ReservationRepository; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static roomescape.Fixtures.themeFixture; + +@ExtendWith(MockitoExtension.class) +@DisplayName("테마 서비스") +class ThemeServiceTest { + @InjectMocks + private ThemeService themeService; + @Mock + private ThemeRepository themeRepository; + @Mock + private ReservationRepository reservationRepository; + + @DisplayName("테마 서비스는 테마를 생성한다.") + @Test + void createTheme() { + // given + Mockito.when(themeRepository.save(any())) + .thenReturn(themeFixture); + ThemeCreateRequest request = new ThemeCreateRequest( + "힐링", + "완전 힐링되는 테마", + "https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg" + ); + + // when + ThemeResponse actual = themeService.createTheme(request); + + // then + SoftAssertions softAssertions = new SoftAssertions(); + softAssertions.assertThat(actual.name()).isEqualTo(themeFixture.getName()); + softAssertions.assertThat(actual.description()).isEqualTo(themeFixture.getDescription()); + softAssertions.assertThat(actual.thumbnail()).isEqualTo(themeFixture.getThumbnail()); + softAssertions.assertAll(); + } + + @DisplayName("테마 서비스는 테마 생성 시 중복된 이름이 들어올 경우 예외가 발생한다.") + @Test + void validateDuplicated() { + // given + Mockito.when(themeRepository.findAll()) + .thenReturn(List.of(themeFixture)); + ThemeCreateRequest request = new ThemeCreateRequest( + "공포", "공포스러운 테마", "http://example.org" + ); + + // when & then + assertThatThrownBy(() -> themeService.createTheme(request)) + .isInstanceOf(DuplicatedException.class) + .hasMessage("중복된 테마 이름입니다."); + } + + @DisplayName("테마 서비스는 모든 테마를 조회한다.") + @Test + void findAll() { + // given + Mockito.when(themeRepository.findAll()) + .thenReturn(List.of(themeFixture)); + + // when + List themeResponses = themeService.readThemes(); + + // then + assertThat(themeResponses).hasSize(1); + } + + @DisplayName("테마 서비스는 최근 일주일 간의 인기 있는 테마를 조회힌다.") + @Test + void readPopularThemes() { + // given + List expected = List.of(5L, 1L, 2L, 3L, 4L); + Mockito.when(reservationRepository.findByDateBetween(any(), any())) + .thenReturn(Fixtures.reservationFixturesForPopularTheme); + + // when + List popularThemes = themeService.readPopularThemes(); + List actual = popularThemes.stream() + .mapToLong(ThemeResponse::id) + .boxed() + .toList(); + + // then + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("테마 서비스는 id에 해당하는 테마를 삭제한다.") + @Test + void delete() { + // given + Long id = 1L; + Mockito.when(reservationRepository.existsByThemeId(id)) + .thenReturn(false); + Mockito.doNothing().when(themeRepository).deleteById(id); + + // when & then + assertThatCode(() -> themeService.deleteTheme(id)) + .doesNotThrowAnyException(); + } + + @DisplayName("테마 서비스는 id에 해당하는 테마 삭제 시 예약이 있는 경우 예외가 발생한다.") + @Test + void deleteThemeWithExistsReservation() { + // given + Long id = 1L; + Mockito.when(reservationRepository.existsByThemeId(id)) + .thenReturn(true); + + // when & then + assertThatThrownBy(() -> themeService.deleteTheme(id)) + .isInstanceOf(ReferencedReservationExistException.class) + .hasMessage("해당 테마에 예약이 존재합니다."); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000000..3c0665609a --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,8 @@ +spring: + h2: + console: + enabled: true + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:roomescape + username: sa diff --git a/src/test/resources/recreate_table.sql b/src/test/resources/recreate_table.sql new file mode 100644 index 0000000000..1706edf7f2 --- /dev/null +++ b/src/test/resources/recreate_table.sql @@ -0,0 +1,47 @@ +DROP TABLE IF EXISTS reservation; +DROP TABLE IF EXISTS reservation_time; +DROP TABLE IF EXISTS theme; + +CREATE TABLE reservation_time +( + id BIGINT NOT NULL AUTO_INCREMENT, + start_at VARCHAR(255) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS theme +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + thumbnail VARCHAR(255) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS reservation +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + date VARCHAR(255) NOT NULL, + time_id BIGINT, + theme_id BIGINT, + PRIMARY KEY (id), + FOREIGN KEY (time_id) REFERENCES reservation_time (id), + FOREIGN KEY (theme_id) REFERENCES theme (id) +); + + +INSERT INTO reservation_time (start_at) VALUES ('10:00:00'); +INSERT INTO reservation_time (start_at) VALUES ('12:00:00'); +INSERT INTO reservation_time (start_at) VALUES ('14:00:00'); +INSERT INTO theme (name, description, thumbnail) VALUES ( '공포', '완전 무서운 테마', 'https://example.org' ); +INSERT INTO theme (name, description, thumbnail) VALUES ( '힐링', '완전 힐링되는 테마', 'https://example.org' ); +INSERT INTO theme (name, description, thumbnail) VALUES ( '힐링2', '완전 힐링되는 테마2', 'https://example.org' ); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '페드로', '2099-12-31', 1, 1); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버', '2099-12-31', 1, 2); + +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버1', '2024-12-01', 1, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버2', '2024-12-02', 1, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버3', '2024-12-02', 2, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버4', '2024-12-03', 1, 2); +INSERT INTO reservation (name, date, time_id, theme_id) VALUES ( '클로버5', '2024-12-04', 1, 2);