package roomescape.reservation.web import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.SpykBean import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.restassured.module.kotlin.extensions.Given import io.restassured.module.kotlin.extensions.Then import io.restassured.module.kotlin.extensions.When import jakarta.persistence.EntityManager import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.equalTo import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.transaction.support.TransactionTemplate import roomescape.auth.web.support.AdminInterceptor import roomescape.auth.web.support.LoginInterceptor import roomescape.auth.web.support.MemberIdResolver import roomescape.common.exception.ErrorType import roomescape.common.exception.RoomescapeException import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.Role import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.TimeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.util.* import java.time.LocalDate import java.time.LocalTime @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ReservationControllerTest( @LocalServerPort val port: Int, val entityManager: EntityManager, val transactionTemplate: TransactionTemplate ) : FunSpec({ extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_EACH_TEST)) }) { @MockkBean lateinit var paymentClient: TossPaymentClient @SpykBean lateinit var loginInterceptor: LoginInterceptor @SpykBean lateinit var adminInterceptor: AdminInterceptor @SpykBean lateinit var memberIdResolver: MemberIdResolver init { context("POST /reservations") { lateinit var member: MemberEntity beforeTest { member = login(MemberFixture.create(role = Role.MEMBER)) } test("정상 응답") { val reservationRequest = createRequest() val paymentApproveResponse = PaymentFixture.createApproveResponse().copy( paymentKey = reservationRequest.paymentKey, orderId = reservationRequest.orderId, totalAmount = reservationRequest.amount, ) every { paymentClient.confirm(any()) } returns paymentApproveResponse Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(reservationRequest) }.When { post("/reservations") }.Then { statusCode(201) body("data.date", equalTo(reservationRequest.date.toString())) body("data.status", equalTo(ReservationStatus.CONFIRMED.name)) } } test("결제 과정에서 발생하는 에러는 그대로 응답") { val reservationRequest = createRequest() val paymentException = RoomescapeException( ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR ) every { paymentClient.confirm(any()) } throws paymentException Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(reservationRequest) }.When { post("/reservations") }.Then { statusCode(paymentException.httpStatus.value()) body("errorType", equalTo(paymentException.errorType.name)) } } test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답") { val reservationRequest = createRequest() val paymentApproveResponse = PaymentFixture.createApproveResponse().copy( paymentKey = reservationRequest.paymentKey, orderId = reservationRequest.orderId, totalAmount = reservationRequest.amount, ) every { paymentClient.confirm(any()) } returns paymentApproveResponse // 예약 저장 과정에서 테마가 없는 예외 val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1) val expectedException = RoomescapeException(ErrorType.THEME_NOT_FOUND, HttpStatus.BAD_REQUEST) every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse() val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery( "SELECT COUNT(c) FROM CanceledPaymentEntity c", Long::class.java ).singleResult Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(invalidRequest) }.When { post("/reservations") }.Then { statusCode(expectedException.httpStatus.value()) body("errorType", equalTo(expectedException.errorType.name)) } val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery( "SELECT COUNT(c) FROM CanceledPaymentEntity c", Long::class.java ).singleResult canceledPaymentSizeAfterApiCall shouldBe canceledPaymentSizeBeforeApiCall + 1L } } context("GET /reservations") { lateinit var reservations: MutableMap> beforeTest { reservations = createDummyReservations() } test("관리자이면 정상 응답") { login(MemberFixture.create(role = Role.ADMIN)) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations") }.Then { statusCode(200) body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size })) } } } context("GET /reservations-mine") { lateinit var reservations: MutableMap> beforeTest { reservations = createDummyReservations() } test("로그인한 회원이 자신의 예약 목록을 조회한다.") { val member: MemberEntity = login(reservations.keys.first()) val expectedReservations: Int = reservations[member]?.size ?: 0 Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations-mine") }.Then { statusCode(200) body("data.reservations.size()", equalTo(expectedReservations)) } } } context("GET /reservations/search") { lateinit var reservations: MutableMap> beforeTest { reservations = createDummyReservations() } test("관리자만 검색할 수 있다.") { login(reservations.keys.first()) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations/search") }.Then { header(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE)) } } test("파라미터를 지정하지 않으면 전체 목록 응답") { login(MemberFixture.create(role = Role.ADMIN)) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations/search") }.Then { statusCode(200) body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size })) } } test("시작 날짜가 종료 날짜 이전이면 예외 응답") { login(MemberFixture.create(role = Role.ADMIN)) val startDate = LocalDate.now().plusDays(1) val endDate = LocalDate.now() Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) param("dateFrom", startDate.toString()) param("dateTo", endDate.toString()) }.When { get("/reservations/search") }.Then { statusCode(HttpStatus.BAD_REQUEST.value()) body("errorType", equalTo(ErrorType.INVALID_DATE_RANGE.name)) } } test("동일한 회원의 모든 예약 응답") { login(MemberFixture.create(role = Role.ADMIN)) val member: MemberEntity = reservations.keys.first() Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) param("memberId", member.id) }.When { get("/reservations/search") }.Then { statusCode(200) body("data.reservations.size()", equalTo(reservations[member]?.size ?: 0)) } } test("동일한 테마의 모든 예약 응답") { login(MemberFixture.create(role = Role.ADMIN)) val themes = reservations.values.flatten().map { it.theme } val requestThemeId: Long = themes.first().id!! Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) param("themeId", requestThemeId) }.When { get("/reservations/search") }.Then { statusCode(200) body("data.reservations.size()", equalTo(themes.filter { it.id == requestThemeId }.size)) } } test("시작 날짜와 종료 날짜 사이의 예약 응답") { login(MemberFixture.create(role = Role.ADMIN)) val dateFrom: LocalDate = reservations.values.flatten().minOf { it.date } val dateTo: LocalDate = reservations.values.flatten().maxOf { it.date } Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) param("dateFrom", dateFrom.toString()) param("dateTo", dateTo.toString()) }.When { get("/reservations/search") }.Then { statusCode(200) body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size })) } } } context("DELETE /reservations/{id}") { lateinit var reservations: MutableMap> beforeTest { reservations = createDummyReservations() } test("관리자만 예약을 삭제할 수 있다.") { login(MemberFixture.create(role = Role.MEMBER)) val reservation: ReservationEntity = reservations.values.flatten().first() Given { port(port) }.When { delete("/reservations/${reservation.id}") }.Then { statusCode(302) header(HttpHeaders.LOCATION, containsString("/login")) } } test("결제되지 않은 예약은 바로 제거") { login(MemberFixture.create(role = Role.ADMIN)) val reservationId: Long = reservations.values.flatten().first().id!! transactionTemplate.execute { val reservation: ReservationEntity = entityManager.find( ReservationEntity::class.java, reservationId ) reservation.reservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED entityManager.persist(reservation) entityManager.flush() entityManager.clear() } Given { port(port) }.When { delete("/reservations/$reservationId") }.Then { statusCode(HttpStatus.NO_CONTENT.value()) } // 예약이 삭제되었는지 확인 transactionTemplate.executeWithoutResult { val deletedReservation = entityManager.find( ReservationEntity::class.java, reservationId ) deletedReservation shouldBe null } } test("결제된 예약은 취소 후 제거") { login(MemberFixture.create(role = Role.ADMIN)) val reservation: ReservationEntity = reservations.values.flatten().first() lateinit var payment: PaymentEntity transactionTemplate.execute { payment = PaymentFixture.create(reservation = reservation).also { entityManager.persist(it) entityManager.flush() entityManager.clear() } } every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse() val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery( "SELECT COUNT(c) FROM CanceledPaymentEntity c", Long::class.java ).singleResult Given { port(port) }.When { delete("/reservations/${reservation.id}") }.Then { statusCode(HttpStatus.NO_CONTENT.value()) } val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery( "SELECT COUNT(c) FROM CanceledPaymentEntity c", Long::class.java ).singleResult canceledPaymentSizeAfterApiCall shouldBe canceledPaymentSizeBeforeApiCall + 1L } } context("POST /reservations/admin") { test("관리자가 예약을 추가하면 결제 대기 상태로 예약 생성") { val member = login(MemberFixture.create(role = Role.ADMIN)) val adminRequest: AdminReservationCreateRequest = createRequest().let { AdminReservationCreateRequest( date = it.date, themeId = it.themeId, timeId = it.timeId, memberId = member.id!!, ) } Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(adminRequest) }.When { post("/reservations/admin") }.Then { statusCode(201) body("data.status", equalTo(ReservationStatus.CONFIRMED_PAYMENT_REQUIRED.name)) } } } context("GET /reservations/waiting") { lateinit var reservations: MutableMap> beforeTest { reservations = createDummyReservations() } test("관리자가 아니면 조회할 수 없다.") { login(MemberFixture.create(role = Role.MEMBER)) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations/waiting") }.Then { header(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE)) } } test("대기 중인 예약 목록을 조회한다.") { login(MemberFixture.create(role = Role.ADMIN)) val expected = reservations.values.flatten() .count { it.reservationStatus == ReservationStatus.WAITING } Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations/waiting") }.Then { statusCode(200) body("data.reservations.size()", equalTo(expected)) } } } context("POST /reservations/waiting") { test("회원이 대기 예약을 추가한다.") { val member = login(MemberFixture.create(role = Role.MEMBER)) val waitingCreateRequest: WaitingCreateRequest = createRequest().let { WaitingCreateRequest( date = it.date, themeId = it.themeId, timeId = it.timeId ) } Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(waitingCreateRequest) }.When { post("/reservations/waiting") }.Then { statusCode(201) body("data.member.id", equalTo(member.id!!.toInt())) body("data.status", equalTo(ReservationStatus.WAITING.name)) } } test("이미 예약된 시간, 테마로 대기 예약 요청 시 예외 응답") { val member = login(MemberFixture.create(role = Role.MEMBER)) val reservationRequest = createRequest() transactionTemplate.executeWithoutResult { val reservation = ReservationFixture.create( date = reservationRequest.date, theme = entityManager.find(ThemeEntity::class.java, reservationRequest.themeId), time = entityManager.find(TimeEntity::class.java, reservationRequest.timeId), member = member, status = ReservationStatus.WAITING ) entityManager.persist(reservation) entityManager.flush() entityManager.clear() } // 이미 예약된 시간, 테마로 대기 예약 요청 val waitingCreateRequest = WaitingCreateRequest( date = reservationRequest.date, themeId = reservationRequest.themeId, timeId = reservationRequest.timeId ) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(waitingCreateRequest) }.When { post("/reservations/waiting") }.Then { statusCode(HttpStatus.BAD_REQUEST.value()) body("errorType", equalTo(ErrorType.HAS_RESERVATION_OR_WAITING.name)) } } } context("DELETE /reservations/waiting/{id}") { lateinit var reservations: MutableMap> beforeTest { reservations = createDummyReservations() } test("대기 중인 예약을 취소한다.") { val member = login(MemberFixture.create(role = Role.MEMBER)) val waiting: ReservationEntity = createSingleReservation( member = member, status = ReservationStatus.WAITING ) Given { port(port) }.When { delete("/reservations/waiting/${waiting.id}") }.Then { statusCode(HttpStatus.NO_CONTENT.value()) } transactionTemplate.executeWithoutResult { _ -> entityManager.find( ReservationEntity::class.java, waiting.id ) shouldBe null } } test("이미 완료된 예약은 삭제할 수 없다.") { val member = login(MemberFixture.create(role = Role.MEMBER)) val reservation: ReservationEntity = createSingleReservation( member = member, status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ) Given { port(port) }.When { delete("/reservations/waiting/{id}", reservation.id) }.Then { body("errorType", equalTo(ErrorType.RESERVATION_NOT_FOUND.name)) statusCode(HttpStatus.NOT_FOUND.value()) } } } context("POST /reservations/waiting/{id}/confirm") { test("관리자만 승인할 수 있다.") { login(MemberFixture.create(role = Role.MEMBER)) Given { port(port) }.When { post("/reservations/waiting/1/confirm") }.Then { statusCode(302) header(HttpHeaders.LOCATION, containsString("/login")) } } test("대기 예약을 승인하면 결제 대기 상태로 변경") { val member = login(MemberFixture.create(role = Role.ADMIN)) val reservation = createSingleReservation( member = member, status = ReservationStatus.WAITING ) Given { port(port) }.When { post("/reservations/waiting/${reservation.id!!}/confirm") }.Then { statusCode(200) } transactionTemplate.executeWithoutResult { _ -> entityManager.find( ReservationEntity::class.java, reservation.id )?.also { it.reservationStatus shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED } ?: throw AssertionError("Reservation not found") } } } context("POST /reservations/waiting/{id}/reject") { test("관리자만 거절할 수 있다.") { login(MemberFixture.create(role = Role.MEMBER)) Given { port(port) }.When { post("/reservations/waiting/1/reject") }.Then { statusCode(302) header(HttpHeaders.LOCATION, containsString("/login")) } } test("거절된 예약은 삭제된다.") { val member = login(MemberFixture.create(role = Role.ADMIN)) val reservation = createSingleReservation( member = member, status = ReservationStatus.WAITING ) Given { port(port) }.When { post("/reservations/waiting/${reservation.id!!}/reject") }.Then { statusCode(204) } transactionTemplate.executeWithoutResult { _ -> entityManager.find( ReservationEntity::class.java, reservation.id ) shouldBe null } } } } fun createSingleReservation( date: LocalDate = LocalDate.now().plusDays(1), time: LocalTime = LocalTime.now(), themeName: String = "Default Theme", member: MemberEntity = MemberFixture.create(role = Role.MEMBER), status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ): ReservationEntity { return ReservationFixture.create( date = date, theme = ThemeFixture.create(name = themeName), time = TimeFixture.create(startAt = time), member = member, status = status ).also { it -> transactionTemplate.execute { _ -> if (member.id == null) { entityManager.persist(member) } entityManager.persist(it.time) entityManager.persist(it.theme) entityManager.persist(it) entityManager.flush() entityManager.clear() } } } fun createDummyReservations(): MutableMap> { val reservations: MutableMap> = mutableMapOf() val members: List = listOf( MemberFixture.create(role = Role.MEMBER), MemberFixture.create(role = Role.MEMBER) ) transactionTemplate.executeWithoutResult { members.forEach { member -> entityManager.persist(member) } entityManager.flush() entityManager.clear() } transactionTemplate.executeWithoutResult { repeat(10) { index -> val theme = ThemeFixture.create(name = "theme$index") val time = TimeFixture.create(startAt = LocalTime.now().plusMinutes(index.toLong())) entityManager.persist(theme) entityManager.persist(time) val reservation = ReservationFixture.create( date = LocalDate.now().plusDays(index.toLong()), theme = theme, time = time, member = members[index % members.size], status = ReservationStatus.CONFIRMED ) entityManager.persist(reservation) reservations.getOrPut(reservation.member) { mutableListOf() }.add(reservation) } entityManager.flush() entityManager.clear() } return reservations } fun createRequest( theme: ThemeEntity = ThemeFixture.create(), time: TimeEntity = TimeFixture.create(), ): ReservationCreateWithPaymentRequest { lateinit var reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest transactionTemplate.executeWithoutResult { entityManager.persist(theme) entityManager.persist(time) reservationCreateWithPaymentRequest = ReservationFixture.createRequest( themeId = theme.id!!, timeId = time.id!!, ) entityManager.flush() entityManager.clear() } return reservationCreateWithPaymentRequest } fun login(member: MemberEntity): MemberEntity { if (member.id == null) { transactionTemplate.executeWithoutResult { entityManager.persist(member) entityManager.flush() entityManager.clear() } } if (member.isAdmin()) { loginAsAdmin() } else { loginAsUser() } resolveMemberId(member.id!!) return member } private fun loginAsUser() { every { loginInterceptor.preHandle(any(), any(), any()) } returns true } private fun loginAsAdmin() { every { adminInterceptor.preHandle(any(), any(), any()) } returns true } private fun resolveMemberId(memberId: Long) { every { memberIdResolver.resolveArgument(any(), any(), any(), any()) } returns memberId } }