730 lines
28 KiB
Kotlin

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<MemberEntity, List<ReservationEntity>>
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<MemberEntity, List<ReservationEntity>>
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<MemberEntity, List<ReservationEntity>>
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<MemberEntity, List<ReservationEntity>>
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<MemberEntity, List<ReservationEntity>>
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<MemberEntity, List<ReservationEntity>> {
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 <T> persist(entity: T): T {
transactionTemplate.executeWithoutResult {
entityManager.persist(entity)
}
return entity
}
}