diff --git a/build.gradle b/build.gradle index 57267157c..02b7d5220 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,12 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured:5.3.1' } diff --git a/src/main/java/roomescape/home/controller/HomeController.java b/src/main/java/roomescape/home/controller/HomeController.java new file mode 100644 index 000000000..b4e8d3fa1 --- /dev/null +++ b/src/main/java/roomescape/home/controller/HomeController.java @@ -0,0 +1,12 @@ +package roomescape.home.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + @GetMapping("/") + public String displayHomePage() { + return "home"; + } +} diff --git a/src/main/java/roomescape/reservation/controller/ReservationController.java b/src/main/java/roomescape/reservation/controller/ReservationController.java new file mode 100644 index 000000000..f6bc2f351 --- /dev/null +++ b/src/main/java/roomescape/reservation/controller/ReservationController.java @@ -0,0 +1,53 @@ +package roomescape.reservation.controller; + +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import roomescape.reservation.dto.ReservationRequest; +import roomescape.reservation.dto.ReservationResponse; +import roomescape.reservation.service.ReservationService; + +@Controller +@RequiredArgsConstructor +public class ReservationController { + private final ReservationService reservationService; + + @GetMapping("/reservation") + public String displayReservationPage() { + return "reservation"; + } + + @GetMapping("/reservations") + @ResponseBody + public ResponseEntity> getReservations() { + return ResponseEntity.ok(reservationService.getReservations()); + } + + @PostMapping("/reservations") + @ResponseBody + public ResponseEntity addReservation(@RequestBody ReservationRequest request) { + ReservationResponse response = reservationService.addReservation(request); + URI location = URI.create("/reservations/" + response.id()); + return ResponseEntity.created(location).body(response); + } + + @DeleteMapping("/reservations/{id}") + public ResponseEntity cancelReservation(@PathVariable Long id) { + reservationService.deleteReservation(id); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } +} diff --git a/src/main/java/roomescape/reservation/dto/ReservationRequest.java b/src/main/java/roomescape/reservation/dto/ReservationRequest.java new file mode 100644 index 000000000..23fb1e3e0 --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/ReservationRequest.java @@ -0,0 +1,12 @@ +package roomescape.reservation.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; +import java.time.LocalTime; + +public record ReservationRequest( + String name, + LocalDate date, + @JsonFormat(pattern = "HH:mm") + LocalTime time) { +} diff --git a/src/main/java/roomescape/reservation/dto/ReservationResponse.java b/src/main/java/roomescape/reservation/dto/ReservationResponse.java new file mode 100644 index 000000000..3b179f29e --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/ReservationResponse.java @@ -0,0 +1,13 @@ +package roomescape.reservation.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; +import java.time.LocalTime; + +public record ReservationResponse( + Long id, + String name, + LocalDate date, + @JsonFormat(pattern = "HH:mm") + LocalTime time) { +} diff --git a/src/main/java/roomescape/reservation/entity/Reservation.java b/src/main/java/roomescape/reservation/entity/Reservation.java new file mode 100644 index 000000000..18304ab6c --- /dev/null +++ b/src/main/java/roomescape/reservation/entity/Reservation.java @@ -0,0 +1,36 @@ +package roomescape.reservation.entity; + +import java.time.LocalDate; +import java.time.LocalTime; +import lombok.Data; + +@Data +public class Reservation { + private Long id; + private String name; + private LocalDate date; + private LocalTime time; + + public Reservation(Long id, String name, LocalDate date, LocalTime time) { + validate(name, date, time); + this.id = id; + this.name = name; + this.date = date; + this.time = time; + } + + private void validate(String name, LocalDate date, LocalTime time) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("유효한 이름이 아닙니다."); + } + if (date == null || date.isBefore(LocalDate.now())) { + throw new IllegalArgumentException("유효한 날짜 값이 아닙니다."); + } + if (date.equals(LocalDate.now()) && time.isBefore(LocalTime.now())) { + throw new IllegalArgumentException("현재 시간보다 이전 시간으로는 예약할 수 없습니다."); + } + if (time == null) { + throw new IllegalArgumentException("유효한 시간이 아닙니다."); + } + } +} diff --git a/src/main/java/roomescape/reservation/exception/NotFoundReservationException.java b/src/main/java/roomescape/reservation/exception/NotFoundReservationException.java new file mode 100644 index 000000000..38c67df58 --- /dev/null +++ b/src/main/java/roomescape/reservation/exception/NotFoundReservationException.java @@ -0,0 +1,7 @@ +package roomescape.reservation.exception; + +public class NotFoundReservationException extends IllegalArgumentException { + public NotFoundReservationException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/reservation/service/ReservationService.java b/src/main/java/roomescape/reservation/service/ReservationService.java new file mode 100644 index 000000000..cdf5f154f --- /dev/null +++ b/src/main/java/roomescape/reservation/service/ReservationService.java @@ -0,0 +1,58 @@ +package roomescape.reservation.service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import roomescape.reservation.dto.ReservationRequest; +import roomescape.reservation.dto.ReservationResponse; +import roomescape.reservation.entity.Reservation; +import roomescape.reservation.exception.NotFoundReservationException; + +@Service +public class ReservationService { + private final Map reservations = new ConcurrentHashMap<>(); + private final AtomicLong index = new AtomicLong(1); + + public List getReservations() { + return reservations.values().stream() + .map(this::toResponseDTO) + .collect(Collectors.toList()); + } + + public ReservationResponse addReservation(ReservationRequest request) { + if (isDuplicate(request)) { + throw new IllegalArgumentException("이미 해당 날짜와 시간에 예약이 존재합니다."); + } + + Long id = index.getAndIncrement(); + Reservation newReservation = new Reservation(id, request.name(), request.date(), request.time()); + reservations.put(id, newReservation); + return toResponseDTO(newReservation); + } + + public void deleteReservation(Long id) { + Reservation target = reservations.remove(id); + if (target == null) { + throw new NotFoundReservationException("해당 ID의 예약이 존재하지 않습니다: "); + } + } + + private ReservationResponse toResponseDTO(Reservation reservation) { + return new ReservationResponse( + reservation.getId(), + reservation.getName(), + reservation.getDate(), + reservation.getTime() + ); + } + + private boolean isDuplicate(ReservationRequest request) { + return reservations.values().stream().anyMatch(reservation -> + reservation.getDate().equals(request.date()) && + reservation.getTime().equals(request.time()) + ); + } +} diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java index cf4efbe91..bc0ae3472 100644 --- a/src/test/java/roomescape/MissionStepTest.java +++ b/src/test/java/roomescape/MissionStepTest.java @@ -1,6 +1,15 @@ package roomescape; +import static org.hamcrest.Matchers.is; + import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; @@ -16,4 +25,109 @@ public class MissionStepTest { .then().log().all() .statusCode(200); } + + @Test + void 이단계() { + RestAssured.given().log().all() + .when().get("/reservation") + .then().log().all() + .statusCode(200); + + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(0)); + } + + @Test + void 삼단계() { + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", "2028-08-05"); + params.put("time", "15:40"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(201) + .header("Location", "/reservations/1") + .body("id", is(1)); + + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(1)); + + RestAssured.given().log().all() + .when().delete("/reservations/1") + .then().log().all() + .statusCode(204); + + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(0)); + } + + @Test + void 사단계() { + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", ""); + params.put("time", ""); + + // 필요한 인자가 없는 경우 + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + + // 삭제할 예약이 없는 경우 + RestAssured.given().log().all() + .when().delete("/reservations/1") + .then().log().all() + .statusCode(400); + } + + @Test + @DisplayName("지난 날짜 예외처리") + void invalidDate() { + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", "2024-01-01"); + params.put("time", "15:00"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + } + + @Test + @DisplayName("오늘 현재 시각보다 빠른 시간 예외처리") + void invalidTodayTime() { + LocalDate today = LocalDate.now(); + LocalTime pastTime = LocalTime.now().minusMinutes(1); + + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", today.toString()); + params.put("time", pastTime.truncatedTo(ChronoUnit.MINUTES).toString()); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + } } diff --git a/src/test/java/roomescape/ReservationServiceTest.java b/src/test/java/roomescape/ReservationServiceTest.java new file mode 100644 index 000000000..df6b480e2 --- /dev/null +++ b/src/test/java/roomescape/ReservationServiceTest.java @@ -0,0 +1,40 @@ +package roomescape; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import roomescape.reservation.dto.ReservationRequest; +import roomescape.reservation.exception.NotFoundReservationException; + +import java.time.LocalDate; +import java.time.LocalTime; +import roomescape.reservation.service.ReservationService; + +class ReservationServiceTest { + + private ReservationService service; + + @BeforeEach + void setUp() { + service = new ReservationService(); + } + + @Test + @DisplayName("동일한 날짜, 시간에 예약하면 예외 발생") + void throwExceptionWhenDuplicateReservation() { + ReservationRequest request = new ReservationRequest("홍길동", LocalDate.now().plusDays(1), LocalTime.of(10, 0)); + service.addReservation(request); + + ReservationRequest duplicateRequest = new ReservationRequest("김철수", LocalDate.now().plusDays(1), LocalTime.of(10, 0)); + + assertThrows(IllegalArgumentException.class, () -> service.addReservation(duplicateRequest)); + } + + @Test + @DisplayName("존재하지 않는 예약 ID로 삭제 시 예외 발생") + void deleteNonExistingReservationThrows() { + assertThrows(NotFoundReservationException.class, () -> service.deleteReservation(999L)); + } +} diff --git a/src/test/java/roomescape/ReservationTest.java b/src/test/java/roomescape/ReservationTest.java new file mode 100644 index 000000000..2015b528f --- /dev/null +++ b/src/test/java/roomescape/ReservationTest.java @@ -0,0 +1,53 @@ +package roomescape; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import roomescape.reservation.entity.Reservation; + +public class ReservationTest { + @Test + @DisplayName("이름이 null이면 예외 발생") + void throwExceptionWhenNameIsNull() { + assertThrows(IllegalArgumentException.class, () -> + new Reservation(1L, null, LocalDate.now().plusDays(1), LocalTime.of(10, 0)) + ); + } + + @Test + @DisplayName("이름이 공백이면 예외 발생") + void throwExceptionWhenNameIsBlank() { + assertThrows(IllegalArgumentException.class, () -> + new Reservation(1L, " ", LocalDate.now().plusDays(1), LocalTime.of(10, 0)) + ); + } + + @Test + @DisplayName("과거 날짜면 예외 발생") + void throwExceptionWhenDateIsPast() { + assertThrows(IllegalArgumentException.class, () -> + new Reservation(1L, "홍길동", LocalDate.now().minusDays(1), LocalTime.of(10, 0)) + ); + } + + @Test + @DisplayName("당일이고 과거 시간이면 예외 발생") + void throwExceptionWhenTimeIsPastToday() { + LocalDate today = LocalDate.now(); + LocalTime pastTime = LocalTime.now().minusMinutes(1); + assertThrows(IllegalArgumentException.class, () -> + new Reservation(1L, "홍길동", today, pastTime) + ); + } + + @Test + @DisplayName("시간이 null이면 예외 발생") + void throwExceptionWhenTimeIsNull() { + assertThrows(IllegalArgumentException.class, () -> + new Reservation(1L, "홍길동", LocalDate.now().plusDays(1), null) + ); + } +}