From 211edcaffdbf30d84de4946f8430da66492f1926 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 9 Sep 2025 09:14:53 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EC=98=88=EC=95=BD=20API=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ReservationApiTest.kt | 571 ++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 src/test/kotlin/roomescape/reservation/ReservationApiTest.kt diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt new file mode 100644 index 00000000..f9589a50 --- /dev/null +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -0,0 +1,571 @@ +package roomescape.reservation + +import io.kotest.matchers.shouldBe +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import roomescape.common.exception.CommonErrorCode +import roomescape.member.infrastructure.persistence.Role +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.infrastructure.common.BankCode +import roomescape.payment.infrastructure.common.CardIssuerCode +import roomescape.payment.infrastructure.common.EasyPayCompanyCode +import roomescape.payment.infrastructure.persistence.PaymentDetailRepository +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.infrastructure.persistence.CanceledReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.ReservationCancelRequest +import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleRepository +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.util.* +import java.time.LocalDate +import java.time.LocalTime + +class ReservationApiTest( + private val reservationRepository: ReservationRepository, + private val canceledReservationRepository: CanceledReservationRepository, + private val scheduleRepository: ScheduleRepository, + private val paymentDetailRepository: PaymentDetailRepository, +) : FunSpecSpringbootTest() { + + init { + context("결제 전 임시 예약을 생성한다.") { + val commonRequest = ReservationFixture.pendingCreateRequest + + test("정상 생성") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = loginUtil.loginAsAdmin(), + request = ScheduleFixture.createRequest, + status = ScheduleStatus.HOLD + ) + + runTest( + token = loginUtil.loginAsUser(), + using = { + body(commonRequest.copy(scheduleId = schedule.id)) + }, + on = { + post("/reservations/pending") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val reservation: ReservationEntity = + reservationRepository.findByIdOrNull(it.extract().path("data.id")) + ?: throw AssertionError("Unexpected Exception Occurred.") + + reservation.status shouldBe ReservationStatus.PENDING + reservation.scheduleId shouldBe schedule.id + reservation.reserverName shouldBe commonRequest.reserverName + } + } + + test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = loginUtil.loginAsAdmin(), + request = ScheduleFixture.createRequest, + status = ScheduleStatus.AVAILABLE + ) + + runTest( + token = loginUtil.loginAsUser(), + using = { + body(commonRequest.copy(scheduleId = schedule.id)) + }, + on = { + post("/reservations/pending") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ReservationErrorCode.SCHEDULE_NOT_HOLD.errorCode)) + } + ) + } + + test("예약 인원이 테마의 최소 인원보다 작거나 최대 인원보다 많으면 실패한다.") { + val adminToken = loginUtil.loginAsAdmin() + val theme: ThemeEntity = dummyInitializer.createTheme( + adminToken = adminToken, + request = ThemeFixture.createRequest + ) + + val schedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = adminToken, + request = ScheduleFixture.createRequest.copy(themeId = theme.id), + status = ScheduleStatus.HOLD + ) + + runTest( + token = loginUtil.loginAsUser(), + using = { + body( + commonRequest.copy( + scheduleId = schedule.id, + participantCount = ((theme.minParticipants - 1).toShort()) + ) + ) + }, + on = { + post("/reservations/pending") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ReservationErrorCode.INVALID_PARTICIPANT_COUNT.errorCode)) + } + ) + + runTest( + token = loginUtil.loginAsUser(), + using = { + body( + commonRequest.copy( + scheduleId = schedule.id, + participantCount = ((theme.maxParticipants + 1).toShort()) + ) + ) + }, + on = { + post("/reservations/pending") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ReservationErrorCode.INVALID_PARTICIPANT_COUNT.errorCode)) + } + ) + } + + context("필수 입력값이 입력되지 않으면 실패한다.") { + test("예약자명") { + runTest( + token = loginUtil.loginAsUser(), + using = { + body(commonRequest.copy(reserverName = "")) + }, + on = { + post("/reservations/pending") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(CommonErrorCode.INVALID_INPUT_VALUE.errorCode)) + } + ) + } + + test("예약자 연락처") { + runTest( + token = loginUtil.loginAsUser(), + using = { + body(commonRequest.copy(reserverContact = "")) + }, + on = { + post("/reservations/pending") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(CommonErrorCode.INVALID_INPUT_VALUE.errorCode)) + } + ) + } + } + } + + context("예약을 확정한다.") { + test("정상 응답") { + val userToken = loginUtil.loginAsUser() + + val reservation: ReservationEntity = dummyInitializer.createPendingReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = userToken, + ) + + runTest( + token = userToken, + on = { + post("/reservations/${reservation.id}/confirm") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedReservation = reservationRepository.findByIdOrNull(reservation.id) + ?: throw AssertionError("Unexpected Exception Occurred.") + + val updatedSchedule = scheduleRepository.findByIdOrNull(updatedReservation.scheduleId) + ?: throw AssertionError("Unexpected Exception Occurred.") + + updatedSchedule.status shouldBe ScheduleStatus.RESERVED + updatedReservation.status shouldBe ReservationStatus.CONFIRMED + } + } + + test("예약이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsUser(), + on = { + post("/reservations/$INVALID_PK/confirm") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ReservationErrorCode.RESERVATION_NOT_FOUND.errorCode)) + } + ) + } + } + + context("예약을 취소한다.") { + test("정상 응답") { + val userToken = loginUtil.loginAsUser() + + val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = userToken, + ) + + runTest( + token = userToken, + using = { + body(ReservationCancelRequest(cancelReason = "test")) + }, + on = { + post("/reservations/${reservation.id}/cancel") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedReservation = reservationRepository.findByIdOrNull(reservation.id) + ?: throw AssertionError("Unexpected Exception Occurred.") + val updatedSchedule = scheduleRepository.findByIdOrNull(updatedReservation.scheduleId) + ?: throw AssertionError("Unexpected Exception Occurred.") + + updatedReservation.status shouldBe ReservationStatus.CANCELED + updatedSchedule.status shouldBe ScheduleStatus.AVAILABLE + canceledReservationRepository.findAll()[0].reservationId shouldBe updatedReservation.id + } + } + + test("예약이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsUser(), + using = { + body(ReservationCancelRequest(cancelReason = "test")) + }, + on = { + post("/reservations/$INVALID_PK/cancel") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ReservationErrorCode.RESERVATION_NOT_FOUND.errorCode)) + } + ) + } + + test("관리자가 아닌 회원은 다른 회원의 예약을 취소할 수 없다.") { + val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsUser(), + ) + + val otherUserToken = loginUtil.login("other@example.com", "other", role = Role.MEMBER) + + runTest( + token = otherUserToken, + using = { + body(ReservationCancelRequest(cancelReason = "test")) + }, + on = { + post("/reservations/${reservation.id}/cancel") + }, + expect = { + statusCode(HttpStatus.FORBIDDEN.value()) + body("code", equalTo(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION.errorCode)) + } + ) + } + + test("관리자는 다른 회원의 예약을 취소할 수 있다.") { + val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsAdmin(), + ) + + val otherAdminToken = loginUtil.login("admin1@example.com", "admin1", role = Role.ADMIN) + + runTest( + token = otherAdminToken, + using = { + body(ReservationCancelRequest(cancelReason = "test")) + }, + on = { + post("/reservations/${reservation.id}/cancel") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedReservation = reservationRepository.findByIdOrNull(reservation.id) + ?: throw AssertionError("Unexpected Exception Occurred.") + val updatedSchedule = scheduleRepository.findByIdOrNull(updatedReservation.scheduleId) + ?: throw AssertionError("Unexpected Exception Occurred.") + + updatedSchedule.status shouldBe ScheduleStatus.AVAILABLE + updatedReservation.status shouldBe ReservationStatus.CANCELED + canceledReservationRepository.findAll()[0].reservationId shouldBe updatedReservation.id + } + } + } + + context("나의 예약 목록을 조회한다.") { + test("정상 응답") { + val userToken = loginUtil.loginAsUser() + val adminToken = loginUtil.loginAsAdmin() + + for (i in 1..3) { + dummyInitializer.createConfirmReservation( + adminToken = adminToken, + reserverToken = userToken, + themeRequest = ThemeFixture.createRequest.copy(name = "theme-$i"), + scheduleRequest = ScheduleFixture.createRequest.copy( + date = LocalDate.now().plusDays(i.toLong()), + time = LocalTime.now().plusHours(i.toLong()) + ) + ) + } + + runTest( + token = userToken, + on = { + get("/reservations/summary") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.reservations.size()", equalTo(3)) + assertProperties( + props = setOf("id", "themeName", "date", "startAt", "status"), + propsNameIfList = "reservations" + ) + } + ) + } + } + + context("예약 상세 정보를 조회한다.") { + context("정상 응답") { + val commonPaymentRequest = PaymentFixture.confirmRequest + + lateinit var reservation: ReservationEntity + + beforeTest { + reservation = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsUser(), + ) + } + + test("카드 결제") { + dummyInitializer.createPayment( + reservationId = reservation.id, + request = commonPaymentRequest, + cardDetail = PaymentFixture.cardDetail( + amount = commonPaymentRequest.amount, + issuerCode = CardIssuerCode.SHINHAN + ) + ) + + runDetailRetrieveTest(reservation).also { + it["method"] shouldBe "카드" + + with(it.get("detail") as LinkedHashMap<*, *>) { + this["type"] shouldBe "CARD" + this["issuerCode"] shouldBe "신한" + } + } + } + + test("카드 + 간편결제") { + dummyInitializer.createPayment( + reservationId = reservation.id, + request = commonPaymentRequest, + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.TOSSPAY, + ), + cardDetail = PaymentFixture.cardDetail( + amount = commonPaymentRequest.amount, + ) + ) + + runDetailRetrieveTest(reservation).also { + it["method"] shouldBe "간편결제" + + with(it.get("detail") as LinkedHashMap<*, *>) { + this["type"] shouldBe "CARD" + this["easypayProviderName"] shouldBe "토스페이" + } + } + } + + test("결제 취소 / 카드 + 간편결제 + 포인트 사용") { + val point = 5_000 + + dummyInitializer.createPayment( + reservationId = reservation.id, + request = commonPaymentRequest, + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.TOSSPAY, + discountAmount = point, + ), + cardDetail = PaymentFixture.cardDetail( + amount = commonPaymentRequest.amount, + issuerCode = CardIssuerCode.SHINHAN + ) + ) + + val cancelReason = "테스트입니다." + val memberId = loginUtil.getUser().id!! + + dummyInitializer.cancelPayment( + memberId = memberId, + reservationId = reservation.id, + cancelReason = cancelReason, + ) + + runDetailRetrieveTest(reservation).also { + it["method"] shouldBe "간편결제" + + with(it.get("detail") as LinkedHashMap<*, *>) { + this["type"] shouldBe "CARD" + this["issuerCode"] shouldBe "신한" + this["easypayProviderName"] shouldBe "토스페이" + this["easypayDiscountAmount"] shouldBe point + } + + with((it.get("cancel") as LinkedHashMap<*, *>)) { + this["cancelReason"] shouldBe cancelReason + this["canceledBy"] shouldBe memberId + } + } + } + + test("간편결제 선불충전금액 사용") { + dummyInitializer.createPayment( + reservationId = reservation.id, + request = commonPaymentRequest, + easyPayDetail = PaymentFixture.easypayDetail( + amount = commonPaymentRequest.amount, + provider = EasyPayCompanyCode.TOSSPAY, + ), + ) + + runDetailRetrieveTest(reservation).also { + it["method"] shouldBe "간편결제" + + with(it.get("detail") as LinkedHashMap<*, *>) { + this["type"] shouldBe "EASYPAY_PREPAID" + this["providerName"] shouldBe "토스페이" + } + } + } + + test("계좌이체 사용") { + dummyInitializer.createPayment( + reservationId = reservation.id, + request = commonPaymentRequest, + transferDetail = PaymentFixture.transferDetail( + bankCode = BankCode.SHINHAN + ) + ) + + runDetailRetrieveTest(reservation).also { + it["method"] shouldBe "계좌이체" + + with(it.get("detail") as LinkedHashMap<*, *>) { + this["type"] shouldBe "BANK_TRANSFER" + this["bankName"] shouldBe "신한" + } + } + } + } + + test("예약이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/reservations/$INVALID_PK/detail") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ReservationErrorCode.RESERVATION_NOT_FOUND.errorCode)) + } + ) + } + + test("예약은 있지만, 결제 정보가 없으면 실패한다.") { + val reservation = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsUser(), + ) + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/reservations/${reservation.id}/detail") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(PaymentErrorCode.PAYMENT_NOT_FOUND.errorCode)) + } + ) + } + + test("예약과 결제는 있지만, 결제 세부 내역이 없으면 실패한다.") { + val reservation = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsUser(), + ) + + dummyInitializer.createPayment( + reservationId = reservation.id, + easyPayDetail = PaymentFixture.easypayDetail(amount = 100_000) + ).also { + paymentDetailRepository.deleteAll() + } + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/reservations/${reservation.id}/detail") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND.errorCode)) + } + ) + } + } + } + + fun runDetailRetrieveTest( + reservation: ReservationEntity + ): LinkedHashMap { + return runTest( + token = loginUtil.loginAsUser(), + on = { + get("/reservations/${reservation.id}/detail") + }, + expect = { + statusCode(HttpStatus.OK.value()) + assertProperties(props = setOf("id", "member", "applicationDateTime", "payment")) + } + ).also { + it.extract().path("data.id") shouldBe reservation.id + it.extract().path("data.member.id") shouldBe reservation.memberId + }.extract().path("data.payment") + } +}