package roomescape.reservation.controller; import static org.assertj.core.api.Assertions.*; import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Map; import java.util.stream.Stream; 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.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.MockitoAnnotations; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.Sql.ExecutionPhase; import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.http.Header; import roomescape.member.infrastructure.persistence.Member; import roomescape.member.infrastructure.persistence.MemberRepository; import roomescape.member.infrastructure.persistence.Role; import roomescape.payment.infrastructure.client.TossPaymentClient; import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity; import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository; import roomescape.payment.infrastructure.persistence.PaymentEntity; import roomescape.payment.infrastructure.persistence.PaymentRepository; import roomescape.payment.web.PaymentApprove; import roomescape.payment.web.PaymentCancel; import roomescape.reservation.domain.Reservation; import roomescape.reservation.domain.ReservationStatus; import roomescape.reservation.domain.ReservationTime; import roomescape.reservation.domain.repository.ReservationRepository; import roomescape.reservation.domain.repository.ReservationTimeRepository; import roomescape.reservation.dto.request.AdminReservationRequest; import roomescape.reservation.dto.request.ReservationRequest; import roomescape.reservation.dto.request.WaitingRequest; import roomescape.theme.infrastructure.persistence.Theme; import roomescape.theme.infrastructure.persistence.ThemeRepository; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) public class ReservationControllerTest { @Autowired private ReservationRepository reservationRepository; @Autowired private ReservationTimeRepository reservationTimeRepository; @Autowired private ThemeRepository themeRepository; @Autowired private MemberRepository memberRepository; @Autowired private PaymentRepository paymentRepository; @Autowired private CanceledPaymentRepository canceledPaymentRepository; @MockBean private TossPaymentClient paymentClient; @LocalServerPort private int port; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test @DisplayName("처음으로 등록하는 예약의 id는 1이다.") void firstPost() { String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); LocalTime time = LocalTime.of(17, 30); LocalDate date = LocalDate.now().plusDays(1L); reservationTimeRepository.save(new ReservationTime(time)); themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Map reservationParams = Map.of( "date", date.toString(), "timeId", "1", "themeId", "1", "paymentKey", "pk", "orderId", "oi", "amount", "1000", "paymentType", "DEFAULT" ); when(paymentClient.confirmPayment(any(PaymentApprove.Request.class))).thenReturn( new PaymentApprove.Response("pk", "oi", OffsetDateTime.of(date, time, ZoneOffset.ofHours(9)), 1000L)); RestAssured.given().log().all() .contentType(ContentType.JSON) .port(port) .header("Cookie", accessTokenCookie) .body(reservationParams) .when().post("/reservations") .then().log().all() .statusCode(201) .body("data.id", is(1)) .header("Location", "/reservations/1"); } @Test @DisplayName("대기중인 예약을 취소한다.") void cancelWaiting() { // given Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); String accessTokenCookie = getAccessTokenCookieByLogin("email@email.com", "password"); ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member member1 = memberRepository.save(new Member(null, "name1", "email1r@email.com", "password", Role.MEMBER)); // when reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member1, ReservationStatus.CONFIRMED)); Reservation waiting = reservationRepository.save( new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member, ReservationStatus.WAITING)); // then RestAssured.given().log().all() .port(port) .header("Cookie", accessTokenCookie) .when().delete("/reservations/waiting/{id}", waiting.getId()) .then().log().all() .statusCode(204); } @Test @DisplayName("회원은 자신이 아닌 다른 회원의 예약을 취소할 수 없다.") void cantCancelOtherMembersWaiting() { // given Member confirmedMember = memberRepository.save( new Member(null, "name", "email@email.com", "password", Role.MEMBER)); String accessTokenCookie = getAccessTokenCookieByLogin("email@email.com", "password"); ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member waitingMember = memberRepository.save( new Member(null, "name1", "email1r@email.com", "password", Role.MEMBER)); // when reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, confirmedMember, ReservationStatus.CONFIRMED)); Reservation waiting = reservationRepository.save( new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, waitingMember, ReservationStatus.WAITING)); // then RestAssured.given().log().all() .port(port) .header("Cookie", accessTokenCookie) .when().delete("/reservations/waiting/{id}", waiting.getId()) .then().log().all() .statusCode(404); } @Test @DisplayName("관리자 권한이 있으면 전체 예약정보를 조회할 수 있다.") void readEmptyReservations() { // given String accessTokenCookie = getAdminAccessTokenCookieByLogin("admin@admin.com", "12341234"); ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); // when reservationRepository.save( new Reservation(LocalDate.now(), reservationTime, theme, member, ReservationStatus.CONFIRMED)); reservationRepository.save(new Reservation(LocalDate.now().plusDays(1), reservationTime, theme, member, ReservationStatus.CONFIRMED)); reservationRepository.save(new Reservation(LocalDate.now().plusDays(2), reservationTime, theme, member, ReservationStatus.CONFIRMED)); // then RestAssured.given().log().all() .port(port) .header(new Header("Cookie", accessTokenCookie)) .when().get("/reservations") .then().log().all() .statusCode(200) .body("data.reservations.size()", is(3)); } @Test @DisplayName("예약 취소는 관리자만 할 수 있다.") void canRemoveMyReservation() { // given Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); String accessTokenCookie = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Reservation reservation = reservationRepository.save( new Reservation(LocalDate.now(), reservationTime, theme, member, ReservationStatus.CONFIRMED)); // when & then RestAssured.given().log().all() .port(port) .header("Cookie", accessTokenCookie) .when().delete("/reservations/" + reservation.getId()) .then().log().all() .statusCode(302); } @Test @DisplayName("관리자가 대기중인 예약을 거절한다.") void denyWaiting() { // given String adminTokenCookie = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member confirmedMember = memberRepository.save( new Member(null, "name1", "email@email.com", "password", Role.MEMBER)); Member waitingMember = memberRepository.save( new Member(null, "name1", "email1@email.com", "password", Role.MEMBER)); reservationRepository.save( new Reservation(LocalDate.now(), reservationTime, theme, confirmedMember, ReservationStatus.CONFIRMED)); Reservation waiting = reservationRepository.save( new Reservation(LocalDate.now(), reservationTime, theme, waitingMember, ReservationStatus.WAITING)); // when & then RestAssured.given().log().all() .port(port) .header("Cookie", adminTokenCookie) .when().post("/reservations/waiting/{id}/deny", waiting.getId()) .then().log().all() .statusCode(204); } @Test @DisplayName("본인의 예약이 아니더라도 관리자 권한이 있으면 예약 정보를 삭제할 수 있다.") void readReservationsSizeAfterPostAndDelete() { // given Member member = memberRepository.save(new Member(null, "name", "admin@admin.com", "password", Role.ADMIN)); String accessTokenCookie = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); ReservationTime reservationTime = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member anotherMember = memberRepository.save( new Member(null, "name", "email@email.com", "password", Role.MEMBER)); Reservation reservation = reservationRepository.save( new Reservation(LocalDate.now(), reservationTime, theme, anotherMember, ReservationStatus.CONFIRMED)); // when & then RestAssured.given().log().all() .port(port) .header("Cookie", accessTokenCookie) .when().delete("/reservations/" + reservation.getId()) .then().log().all() .statusCode(204); } @ParameterizedTest @MethodSource("requestValidateSource") @DisplayName("예약 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.") void validateBlankRequest(Map invalidRequestBody) { RestAssured.given().log().all() .contentType(ContentType.JSON) .port(port) .body(invalidRequestBody) .header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a")) .when().post("/reservations") .then().log().all() .statusCode(400); } private static Stream> requestValidateSource() { return Stream.of( Map.of("timeId", "1", "themeId", "1"), Map.of("date", LocalDate.now().plusDays(1L).toString(), "themeId", "1"), Map.of("date", LocalDate.now().plusDays(1L).toString(), "timeId", "1"), Map.of("date", " ", "timeId", "1", "themeId", "1"), Map.of("date", LocalDate.now().plusDays(1L).toString(), "timeId", " ", "themeId", "1"), Map.of("date", LocalDate.now().plusDays(1L).toString(), "timeId", "1", "themeId", " ") ); } @Test @DisplayName("예약 생성 시, 정수 요청 데이터에 문자가 입력되어오면 400 에러를 발생한다.") void validateRequestDataFormat() { Map invalidTypeRequestBody = Map.of( "date", LocalDate.now().plusDays(1L).toString(), "timeId", "1", "themeId", "한글" ); RestAssured.given().log().all() .contentType(ContentType.JSON) .port(port) .header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a")) .body(invalidTypeRequestBody) .when().post("/reservations") .then().log().all() .statusCode(400); } @ParameterizedTest @DisplayName("모든 예약 / 대기 중인 예약 / 현재 로그인된 회원의 예약 및 대기를 조회한다.") @CsvSource(value = {"/reservations, reservations, 2", "/reservations/waiting, reservations, 1", "/reservations-mine, myReservationResponses, 3"}, delimiter = ',') void getAllReservations(String requestURI, String responseFieldName, int expectedSize) { // given LocalDate date = LocalDate.now().plusDays(1); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); ReservationTime time1 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(18, 30))); ReservationTime time2 = reservationTimeRepository.save(new ReservationTime(LocalTime.of(19, 30))); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.ADMIN)); String accessToken = getAccessTokenCookieByLogin("email@email.com", "password"); // when : 예약은 2개, 예약 대기는 1개 조회되어야 한다. reservationRepository.save(new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED)); reservationRepository.save( new Reservation(date, time1, theme, member, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); reservationRepository.save(new Reservation(date, time2, theme, member, ReservationStatus.WAITING)); // then RestAssured.given().log().all() .port(port) .header("Cookie", accessToken) .when().get(requestURI) .then().log().all() .statusCode(200) .body("data." + responseFieldName + ".size()", is(expectedSize)); } @Test @DisplayName("예약을 삭제할 때, 승인되었으나 결제 대기중인 예약은 결제 취소 없이 바로 삭제한다.") void removeNotPaidReservation() { // given LocalDate date = LocalDate.now().plusDays(1); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); // when Reservation saved = reservationRepository.save(new Reservation(date, time, theme, memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)), ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); // then RestAssured.given().log().all() .port(port) .header("Cookie", accessToken) .when().delete("/reservations/{id}", saved.getId()) .then().log().all() .statusCode(204); } @Test @DisplayName("이미 결제가 된 예약은 삭제 후 결제 취소를 요청한다.") void removePaidReservation() { // given String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); LocalDate date = LocalDate.now().plusDays(1); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); ReservationTime time = reservationTimeRepository.save(new ReservationTime(LocalTime.of(17, 30))); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); Reservation saved = reservationRepository.save( new Reservation(date, time, theme, member, ReservationStatus.CONFIRMED)); PaymentEntity savedPaymentEntity = paymentRepository.save( new PaymentEntity(null, "pk", "oi", 1000L, saved, OffsetDateTime.now().minusHours(1L))); // when when(paymentClient.cancelPayment(any(PaymentCancel.Request.class))) .thenReturn(new PaymentCancel.Response("pk", "고객 요청", savedPaymentEntity.getTotalAmount(), OffsetDateTime.now())); // then RestAssured.given().log().all() .port(port) .header("Cookie", accessToken) .when().delete("/reservations/{id}", saved.getId()) .then().log().all() .statusCode(204); } @Test @DisplayName("예약을 추가할 때, 결제 승인 이후에 예외가 발생하면 결제를 취소한 뒤 결제 취소 테이블에 취소 정보를 저장한다.") void saveReservationWithCancelPayment() { // given LocalDateTime localDateTime = LocalDateTime.now().minusHours(1L).withNano(0); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); String accessToken = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); // when : 이전 날짜의 예약을 추가하여 결제 승인 이후 DB 저장 과정에서 예외를 발생시킨다. String paymentKey = "pk"; OffsetDateTime canceledAt = OffsetDateTime.now().plusHours(1L).withNano(0); OffsetDateTime approvedAt = OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(9)); when(paymentClient.confirmPayment(any(PaymentApprove.Request.class))) .thenReturn(new PaymentApprove.Response(paymentKey, "oi", approvedAt, 1000L)); when(paymentClient.cancelPayment(any(PaymentCancel.Request.class))) .thenReturn(new PaymentCancel.Response(paymentKey, "고객 요청", 1000L, canceledAt)); RestAssured.given().log().all() .contentType(ContentType.JSON) .port(port) .header("Cookie", accessToken) .body(new ReservationRequest(date, time.getId(), theme.getId(), "pk", "oi", 1000L, "DEFAULT")) .when().post("/reservations") .then().log().all() .statusCode(400); // then CanceledPaymentEntity canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentKey); assertThat(canceledPayment).isNotNull(); assertThat(canceledPayment.getCanceledAt()).isEqualTo(canceledAt); assertThat(canceledPayment.getCancelReason()).isEqualTo("고객 요청"); assertThat(canceledPayment.getCancelAmount()).isEqualTo(1000L); assertThat(canceledPayment.getApprovedAt()).isEqualTo(approvedAt); } @DisplayName("테마만을 이용하여 예약을 조회한다.") @ParameterizedTest(name = "테마 ID={0}로 조회 시 {1}개의 예약이 조회된다.") @CsvSource(value = {"1/4", "2/3"}, delimiter = '/') @Sql({"/truncate.sql", "/test_search_data.sql"}) void searchByTheme(String themeId, int expectedCount) { RestAssured.given().log().all() .port(port) .param("themeId", themeId) .param("memberId", "") .param("dateFrom", "") .param("dateTo", "") .header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password")) .when().get("/reservations/search") .then().log().all() .statusCode(HttpStatus.OK.value()) .body("data.reservations.size()", is(expectedCount)); } @DisplayName("시작 날짜만을 이용하여 예약을 조회한다.") @ParameterizedTest(name = "오늘 날짜보다 {0}일 전인 날짜를 시작 날짜로 조회 시 {1}개의 예약이 조회된다.") @CsvSource(value = {"1/1", "7/7"}, delimiter = '/') @Sql({"/truncate.sql", "/test_search_data.sql"}) void searchByFromDate(int minusDays, int expectedCount) { RestAssured.given().log().all() .port(port) .param("themeId", "") .param("memberId", "") .param("dateFrom", LocalDate.now().minusDays(minusDays).toString()) .param("dateTo", "") .header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password")) .when().get("/reservations/search") .then().log().all() .statusCode(HttpStatus.OK.value()) .body("data.reservations.size()", is(expectedCount)); } @DisplayName("종료 날짜만을 이용하여 예약을 조회한다..") @ParameterizedTest(name = "오늘 날짜보다 {0}일 전인 날짜를 종료 날짜로 조회 시 {1}개의 예약이 조회된다.") @CsvSource(value = {"1/7", "3/5", "7/1"}, delimiter = '/') @Sql({"/truncate.sql", "/test_search_data.sql"}) void searchByToDate(int minusDays, int expectedCount) { RestAssured.given().log().all() .port(port) .param("themeId", "") .param("memberId", "") .param("dateFrom", "") .param("dateTo", LocalDate.now().minusDays(minusDays).toString()) .header("cookie", getAdminAccessTokenCookieByLogin("admin@email.com", "password")) .when().get("/reservations/search") .then().log().all() .statusCode(HttpStatus.OK.value()) .body("data.reservations.size()", is(expectedCount)); } @Test @DisplayName("예약 대기를 추가한다.") void addWaiting() { // given LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); Member member1 = memberRepository.save(new Member(null, "name1", "email1@email.com", "password", Role.MEMBER)); String accessToken = getAccessTokenCookieByLogin(member.getEmail(), member.getPassword()); reservationRepository.save(new Reservation(date, time, theme, member1, ReservationStatus.CONFIRMED)); // when & then RestAssured.given().log().all() .port(port) .contentType(ContentType.JSON) .header("Cookie", accessToken) .body(new WaitingRequest(date, time.getId(), theme.getId())) .when().post("/reservations/waiting") .then().log().all() .statusCode(201) .body("data.status", is("WAITING")); } @Test @DisplayName("대기중인 예약을 승인한다.") void approveWaiting() { // given LocalDateTime localDateTime = LocalDateTime.now().plusHours(1L); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); String accessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); Reservation waiting = reservationRepository.save( new Reservation(date, time, theme, member, ReservationStatus.WAITING)); // when RestAssured.given().log().all() .port(port) .header("Cookie", accessToken) .when().post("/reservations/waiting/{id}/approve", waiting.getId()) .then().log().all() .statusCode(200); // then reservationRepository.findById(waiting.getId()) .ifPresent(r -> assertThat(r.getReservationStatus()).isEqualTo( ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)); } private String getAccessTokenCookieByLogin(final String email, final String password) { Map loginParams = Map.of( "email", email, "password", password ); String accessToken = RestAssured.given().log().all() .contentType(ContentType.JSON) .port(port) .body(loginParams) .when().post("/login") .then().log().all().extract().cookie("accessToken"); return "accessToken=" + accessToken; } @Test @DisplayName("관리자가 직접 예약을 추가한다.") void addReservationByAdmin() { // given LocalDateTime localDateTime = LocalDateTime.now().plusDays(1L).withNano(0); LocalDate date = localDateTime.toLocalDate(); ReservationTime time = reservationTimeRepository.save(new ReservationTime(localDateTime.toLocalTime())); Theme theme = themeRepository.save(new Theme("테마명", "설명", "썸네일URL")); Member member = memberRepository.save(new Member(null, "name", "email@email.com", "password", Role.MEMBER)); String adminAccessToken = getAdminAccessTokenCookieByLogin("admin@email.com", "password"); // when & then RestAssured.given().log().all() .port(port) .contentType(ContentType.JSON) .header("Cookie", adminAccessToken) .body(new AdminReservationRequest(date, time.getId(), theme.getId(), member.getId())) .when().post("/reservations/admin") .then().log().all() .statusCode(201); } private String getAdminAccessTokenCookieByLogin(final String email, final String password) { memberRepository.save(new Member(null, "이름", email, password, Role.ADMIN)); Map loginParams = Map.of( "email", email, "password", password ); String accessToken = RestAssured.given().log().all() .contentType(ContentType.JSON) .port(port) .body(loginParams) .when().post("/login") .then().log().all().extract().cookie("accessToken"); return "accessToken=" + accessToken; } }