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.equalTo import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.transaction.support.TransactionTemplate import roomescape.auth.exception.AuthErrorCode import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.web.support.MemberIdResolver import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.Role import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentException import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.reservation.exception.ReservationErrorCode import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.time.infrastructure.persistence.TimeEntity 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 memberIdResolver: MemberIdResolver @SpykBean lateinit var memberService: MemberService @MockkBean lateinit var jwtHandler: JwtHandler lateinit var testDataHelper: TestDataHelper fun login(member: MemberEntity) { every { jwtHandler.getMemberIdFromToken(any()) } returns member.id!! every { memberService.findById(member.id!!) } returns member every { memberIdResolver.resolveArgument(any(), any(), any(), any()) } returns member.id!! } init { beforeSpec { testDataHelper = TestDataHelper(entityManager, transactionTemplate) } context("POST /reservations") { beforeTest { val member = testDataHelper.createMember(role = Role.MEMBER) login(member) } test("정상 응답") { val reservationRequest = testDataHelper.createReservationRequest() 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 = testDataHelper.createReservationRequest() val paymentException = PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) every { paymentClient.confirm(any()) } throws paymentException Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(reservationRequest) }.When { post("/reservations") }.Then { statusCode(paymentException.errorCode.httpStatus.value()) body("code", equalTo(paymentException.errorCode.errorCode)) } } test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답을 받는다.") { val reservationRequest = testDataHelper.createReservationRequest() 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 = ThemeErrorCode.THEME_NOT_FOUND 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("code", equalTo(expectedException.errorCode)) } 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: Map> beforeTest { reservations = testDataHelper.createDummyReservations() } test("관리자이면 정상 응답") { login(testDataHelper.createMember(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: Map> beforeTest { reservations = testDataHelper.createDummyReservations() } test("로그인한 회원이 자신의 예약 목록을 조회한다.") { val member = reservations.keys.first() login(member) 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: Map> beforeTest { reservations = testDataHelper.createDummyReservations() } test("관리자만 검색할 수 있다.") { login(reservations.keys.first()) val expectedError = AuthErrorCode.ACCESS_DENIED Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations/search") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } test("파라미터를 지정하지 않으면 전체 목록 응답") { login(testDataHelper.createMember(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(testDataHelper.createMember(role = Role.ADMIN)) val startDate = LocalDate.now().plusDays(1) val endDate = LocalDate.now() val expectedError = ReservationErrorCode.INVALID_SEARCH_DATE_RANGE Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) param("dateFrom", startDate.toString()) param("dateTo", endDate.toString()) }.When { get("/reservations/search") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } test("동일한 회원의 모든 예약 응답") { login(testDataHelper.createMember(role = Role.ADMIN)) val member = 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(testDataHelper.createMember(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.count { it.id == requestThemeId })) } } test("시작 날짜와 종료 날짜 사이의 예약 응답") { login(testDataHelper.createMember(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: Map> beforeTest { reservations = testDataHelper.createDummyReservations() } test("관리자만 예약을 삭제할 수 있다.") { login(testDataHelper.createMember(role = Role.MEMBER)) val reservation = reservations.values.flatten().first() val expectedError = AuthErrorCode.ACCESS_DENIED Given { port(port) }.When { delete("/reservations/${reservation.id}") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } test("결제되지 않은 예약은 바로 제거") { login(testDataHelper.createMember(role = Role.ADMIN)) val reservationId = reservations.values.flatten().first().id!! transactionTemplate.executeWithoutResult { val reservation = entityManager.find(ReservationEntity::class.java, reservationId) reservation.status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED } Given { port(port) }.When { delete("/reservations/$reservationId") }.Then { statusCode(HttpStatus.NO_CONTENT.value()) } val deletedReservation = transactionTemplate.execute { entityManager.find(ReservationEntity::class.java, reservationId) } deletedReservation shouldBe null } test("결제된 예약은 취소 후 제거") { login(testDataHelper.createMember(role = Role.ADMIN)) val reservation = reservations.values.flatten().first { it.status == ReservationStatus.CONFIRMED } testDataHelper.createPayment(reservation) 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 admin = testDataHelper.createMember(role = Role.ADMIN) login(admin) val theme = testDataHelper.createTheme() val time = testDataHelper.createTime() val adminRequest = AdminReservationCreateRequest( date = LocalDate.now().plusDays(1), themeId = theme.id!!, timeId = time.id!!, memberId = admin.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: Map> beforeTest { reservations = testDataHelper.createDummyReservations(reservationCount = 5) } test("관리자가 아니면 조회할 수 없다.") { login(testDataHelper.createMember(role = Role.MEMBER)) val expectedError = AuthErrorCode.ACCESS_DENIED Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) }.When { get("/reservations/waiting") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } test("대기 중인 예약 목록을 조회한다.") { login(testDataHelper.createMember(role = Role.ADMIN)) val expected = reservations.values.flatten() .count { it.status == 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 = testDataHelper.createMember(role = Role.MEMBER) login(member) val theme = testDataHelper.createTheme() val time = testDataHelper.createTime() val waitingCreateRequest = WaitingCreateRequest( date = LocalDate.now().plusDays(1), themeId = theme.id!!, timeId = time.id!! ) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(waitingCreateRequest) }.When { post("/reservations/waiting") }.Then { statusCode(201) body("data.member.id", equalTo(member.id!!)) body("data.status", equalTo(ReservationStatus.WAITING.name)) } } test("이미 예약된 시간, 테마로 대기 예약 요청 시 예외 응답") { val member = testDataHelper.createMember(role = Role.MEMBER) login(member) val theme = testDataHelper.createTheme() val time = testDataHelper.createTime() val date = LocalDate.now().plusDays(1) testDataHelper.createReservation( date = date, theme = theme, time = time, member = member, status = ReservationStatus.CONFIRMED ) val waitingCreateRequest = WaitingCreateRequest( date = date, themeId = theme.id!!, timeId = time.id!! ) val expectedError = ReservationErrorCode.ALREADY_RESERVE Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) body(waitingCreateRequest) }.When { post("/reservations/waiting") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } } context("DELETE /reservations/waiting/{id}") { test("대기 중인 예약을 취소한다.") { val member = testDataHelper.createMember(role = Role.MEMBER) login(member) val waiting = testDataHelper.createReservation( member = member, status = ReservationStatus.WAITING ) Given { port(port) }.When { delete("/reservations/waiting/${waiting.id}") }.Then { statusCode(HttpStatus.NO_CONTENT.value()) } val deleted = transactionTemplate.execute { entityManager.find(ReservationEntity::class.java, waiting.id) } deleted shouldBe null } test("이미 확정된 예약을 삭제하면 예외 응답") { val member = testDataHelper.createMember(role = Role.MEMBER) login(member) val reservation = testDataHelper.createReservation( member = member, status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ) val expectedError = ReservationErrorCode.ALREADY_CONFIRMED Given { port(port) }.When { delete("/reservations/waiting/${reservation.id}") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } } context("POST /reservations/waiting/{id}/confirm") { test("관리자만 승인할 수 있다.") { login(testDataHelper.createMember(role = Role.MEMBER)) val expectedError = AuthErrorCode.ACCESS_DENIED Given { port(port) }.When { post("/reservations/waiting/1/confirm") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } test("대기 예약을 승인하면 결제 대기 상태로 변경") { login(testDataHelper.createMember(role = Role.ADMIN)) val reservation = testDataHelper.createReservation( status = ReservationStatus.WAITING ) Given { port(port) }.When { post("/reservations/waiting/${reservation.id!!}/confirm") }.Then { statusCode(200) } val updatedReservation = transactionTemplate.execute { entityManager.find(ReservationEntity::class.java, reservation.id) } updatedReservation?.status shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED } test("다른 확정된 예약을 승인하면 예외 응답") { val admin = testDataHelper.createMember(role = Role.ADMIN) login(admin) val alreadyReserved = testDataHelper.createReservation( member = admin, status = ReservationStatus.CONFIRMED ) val member = testDataHelper.createMember(role = Role.MEMBER) val waiting = testDataHelper.createReservation( date = alreadyReserved.date, time = alreadyReserved.time, theme = alreadyReserved.theme, member = member, status = ReservationStatus.WAITING ) val expectedError = ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS Given { port(port) }.When { post("/reservations/waiting/${waiting.id!!}/confirm") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } } context("POST /reservations/waiting/{id}/reject") { test("관리자만 거절할 수 있다.") { login(testDataHelper.createMember(role = Role.MEMBER)) val expectedError = AuthErrorCode.ACCESS_DENIED Given { port(port) }.When { post("/reservations/waiting/1/reject") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } } test("거절된 예약은 삭제된다.") { login(testDataHelper.createMember(role = Role.ADMIN)) val reservation = testDataHelper.createReservation( status = ReservationStatus.WAITING ) Given { port(port) }.When { post("/reservations/waiting/${reservation.id!!}/reject") }.Then { statusCode(204) } val rejected = transactionTemplate.execute { entityManager.find(ReservationEntity::class.java, reservation.id) } rejected shouldBe null } } } } class TestDataHelper( private val entityManager: EntityManager, private val transactionTemplate: TransactionTemplate, ) { private var memberSequence = 0L private var themeSequence = 0L private var timeSequence = 0L fun createMember( role: Role = Role.MEMBER, account: String = "member${++memberSequence}@test.com", ): MemberEntity { val member = MemberFixture.create(role = role, account = account) return persist(member) } fun createTheme(name: String = "theme-${++themeSequence}"): ThemeEntity { val theme = ThemeFixture.create(name = name) return persist(theme) } fun createTime(startAt: LocalTime = LocalTime.of(10, 0).plusMinutes(++timeSequence * 10)): TimeEntity { val time = TimeFixture.create(startAt = startAt) return persist(time) } fun createReservation( date: LocalDate = LocalDate.now().plusDays(1), theme: ThemeEntity = createTheme(), time: TimeEntity = createTime(), member: MemberEntity = createMember(), status: ReservationStatus = ReservationStatus.CONFIRMED, ): ReservationEntity { val reservation = ReservationFixture.create( date = date, theme = theme, time = time, member = member, status = status ) return persist(reservation) } fun createPayment(reservation: ReservationEntity): PaymentEntity { val payment = PaymentFixture.create(reservation = reservation) return persist(payment) } fun createReservationRequest( theme: ThemeEntity = createTheme(), time: TimeEntity = createTime(), ): ReservationCreateWithPaymentRequest { return ReservationFixture.createRequest( themeId = theme.id!!, timeId = time.id!!, ) } fun createDummyReservations( memberCount: Int = 2, reservationCount: Int = 10, ): Map> { val members = (1..memberCount).map { createMember(role = Role.MEMBER) } val reservations = (1..reservationCount).map { index -> createReservation( member = members[index % memberCount], status = ReservationStatus.CONFIRMED ) } return reservations.groupBy { it.member } } private fun persist(entity: T): T { transactionTemplate.executeWithoutResult { entityManager.persist(entity) } return entity } }