793 lines
30 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.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.confirmPayment(any())
} returns paymentApproveResponse
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(reservationRequest)
}.When {
post("/reservations")
}.Then {
log().all()
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.confirmPayment(any())
} throws paymentException
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(reservationRequest)
}.When {
post("/reservations")
}.Then {
log().all()
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.confirmPayment(any())
} returns paymentApproveResponse
// 예약 저장 과정에서 테마가 없는 예외
val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1)
val expectedException = RoomescapeException(ErrorType.THEME_NOT_FOUND, HttpStatus.BAD_REQUEST)
every {
paymentClient.cancelPayment(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 {
log().all()
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<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자이면 정상 응답") {
login(MemberFixture.create(role = Role.ADMIN))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations")
}.Then {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
}
context("GET /reservations-mine") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
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 {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(expectedReservations))
}
}
}
context("GET /reservations/search") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자만 검색할 수 있다.") {
login(reservations.keys.first())
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/search")
}.Then {
log().all()
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 {
log().all()
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 {
log().all()
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 {
log().all()
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 {
log().all()
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 {
log().all()
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
}
context("DELETE /reservations/{id}") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
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 {
log().all()
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 {
log().all()
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.cancelPayment(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 {
log().all()
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 {
log().all()
statusCode(201)
body("data.status", equalTo(ReservationStatus.CONFIRMED_PAYMENT_REQUIRED.name))
}
}
}
context("GET /reservations/waiting") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("관리자가 아니면 조회할 수 없다.") {
login(MemberFixture.create(role = Role.MEMBER))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/waiting")
}.Then {
log().all()
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 {
log().all()
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 {
log().all()
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 {
log().all()
statusCode(HttpStatus.BAD_REQUEST.value())
body("errorType", equalTo(ErrorType.HAS_RESERVATION_OR_WAITING.name))
}
}
}
context("DELETE /reservations/waiting/{id}") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
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 {
log().all()
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 {
log().all()
body("errorType", equalTo(ErrorType.RESERVATION_NOT_FOUND.name))
statusCode(HttpStatus.NOT_FOUND.value())
}
}
}
context("POST /reservations/waiting/{id}/approve") {
test("관리자만 승인할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
Given {
port(port)
}.When {
post("/reservations/waiting/1/approve")
}.Then {
log().all()
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!!}/approve")
}.Then {
log().all()
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}/deny") {
test("관리자만 거절할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
Given {
port(port)
}.When {
post("/reservations/waiting/1/deny")
}.Then {
log().all()
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!!}/deny")
}.Then {
log().all()
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<MemberEntity, MutableList<ReservationEntity>> {
val reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>> = mutableMapOf()
val members: List<MemberEntity> = 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
}
}