[#41] 예약 스키마 재정의 #42

Merged
pricelees merged 41 commits from refactor/#41 into main 2025-09-09 00:43:39 +00:00
18 changed files with 0 additions and 1094 deletions
Showing only changes of commit 3243c936c7 - Show all commits

View File

@ -1,48 +0,0 @@
package roomescape.payment.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentFinder(
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
) {
fun existsPaymentByReservationId(reservationId: Long): Boolean {
log.debug { "[PaymentFinder.existsPaymentByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.existsByReservationId(reservationId)
.also { log.debug { "[PaymentFinder.existsPaymentByReservationId] 완료: reservationId=$reservationId, isExist=$it" } }
}
fun findByReservationId(reservationId: Long): PaymentEntity {
log.debug { "[PaymentFinder.findByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.debug { "[PaymentFinder.findByReservationId] 완료: reservationId=$reservationId" } }
?: run {
log.warn { "[PaymentFinder.findByReservationId] 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
fun findCanceledByKey(paymentKey: String): CanceledPaymentEntity {
log.debug { "[PaymentFinder.findCanceledByKey] 시작: paymentKey=$paymentKey" }
return canceledPaymentRepository.findByPaymentKey(paymentKey)
?.also { log.debug { "[PaymentFinder.findCanceledByKey] 완료: canceledPaymentId=${it.id}" } }
?: run {
log.warn { "[PaymentFinder.findCanceledByKey] 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND)
}
}
}

View File

@ -1,53 +0,0 @@
package roomescape.payment.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentRepositoryV2
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailRepository
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentRepositoryV2
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentFinderV2(
private val paymentRepository: PaymentRepositoryV2,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepositoryV2
) {
fun findPaymentByReservationId(reservationId: Long): PaymentEntityV2 {
log.debug { "[PaymentFinderV2.findByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.findByReservationId(reservationId)?.also {
log.debug { "[PaymentFinderV2.findByReservationId] 완료: reservationId=$reservationId, paymentId=${it.id}" }
} ?: run {
log.warn { "[PaymentFinderV2.findByReservationId] 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
fun findPaymentDetailByPaymentId(paymentId: Long): PaymentDetailEntity {
log.debug { "[PaymentFinderV2.findPaymentDetailByPaymentId] 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId)?.also {
log.debug { "[PaymentFinderV2.findPaymentDetailByPaymentId] 완료: paymentId=$paymentId, detailId=${it.id}" }
} ?: run {
log.warn { "[PaymentFinderV2.findPaymentDetailByPaymentId] 실패: paymentId=$paymentId" }
throw PaymentException(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND)
}
}
fun findCanceledPaymentByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntityV2? {
log.debug { "[PaymentFinderV2.findCanceledPaymentByKey] 시작: paymentId=$paymentId" }
return canceledPaymentRepository.findByPaymentId(paymentId)?.also {
log.debug { "[PaymentFinderV2.findCanceledPaymentByKey] 완료: paymentId=$paymentId, canceledPaymentId=${it.id}" }
}
}
}

View File

@ -1,21 +0,0 @@
package roomescape.payment.implement
import org.springframework.stereotype.Component
import roomescape.payment.infrastructure.client.v2.*
@Component
class PaymentRequester(
private val client: TosspaymentClientV2
) {
fun requestConfirmPayment(paymentKey: String, orderId: String, amount: Int): PaymentConfirmResponse {
val request = PaymentConfirmRequest(paymentKey, orderId, amount)
return client.confirm(request)
}
fun requestCancelPayment(paymentKey: String, amount: Int, cancelReason: String): PaymentCancelResponseV2 {
val request = PaymentCancelRequestV2(paymentKey, amount, cancelReason)
return client.cancel(request)
}
}

View File

@ -1,81 +0,0 @@
package roomescape.payment.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentWriter(
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val tsidFactory: TsidFactory,
) {
fun create(
paymentKey: String,
orderId: String,
totalAmount: Long,
approvedAt: OffsetDateTime,
reservation: ReservationEntity
): PaymentEntity {
log.debug { "[PaymentWriter.create] 시작: paymentKey=${paymentKey}, reservationId=${reservation.id}" }
val payment = PaymentEntity(
_id = tsidFactory.next(),
orderId = orderId,
paymentKey = paymentKey,
totalAmount = totalAmount,
reservation = reservation,
approvedAt = approvedAt
)
return paymentRepository.save(payment)
.also { log.debug { "[PaymentWriter.create] 완료: paymentId=${it.id}, reservationId=${reservation.id}" } }
}
fun createCanceled(
payment: PaymentEntity,
cancelReason: String,
canceledAt: OffsetDateTime,
): CanceledPaymentEntity = createCanceled(
cancelReason = cancelReason,
canceledAt = canceledAt,
cancelAmount = payment.totalAmount,
approvedAt = payment.approvedAt,
paymentKey = payment.paymentKey
)
fun createCanceled(
cancelReason: String,
cancelAmount: Long,
canceledAt: OffsetDateTime,
approvedAt: OffsetDateTime,
paymentKey: String,
): CanceledPaymentEntity {
log.debug { "[PaymentWriter.createCanceled] 시작: paymentKey=$paymentKey cancelAmount=$cancelAmount" }
val canceledPayment = CanceledPaymentEntity(
_id = tsidFactory.next(),
paymentKey = paymentKey,
cancelReason = cancelReason,
cancelAmount = cancelAmount,
approvedAt = approvedAt,
canceledAt = canceledAt
)
return canceledPaymentRepository.save(canceledPayment)
.also {
paymentRepository.deleteByPaymentKey(paymentKey)
log.debug { "[PaymentWriter.createCanceled] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
}
}
}

View File

@ -1,29 +0,0 @@
package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.TreeNode
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import roomescape.payment.web.PaymentCancelResponse
import java.time.OffsetDateTime
class PaymentCancelResponseDeserializer(
vc: Class<PaymentCancelResponse>? = null
) : StdDeserializer<PaymentCancelResponse>(vc) {
override fun deserialize(
jsonParser: JsonParser,
deserializationContext: DeserializationContext?
): PaymentCancelResponse {
val cancels: JsonNode = jsonParser.codec.readTree<TreeNode>(jsonParser)
.get("cancels")
.get(0) as JsonNode
return PaymentCancelResponse(
cancels.get("cancelStatus").asText(),
cancels.get("cancelReason").asText(),
cancels.get("cancelAmount").asLong(),
OffsetDateTime.parse(cancels.get("canceledAt").asText())
)
}
}

View File

@ -1,108 +0,0 @@
package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpRequest
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import java.util.Map
private val log: KLogger = KotlinLogging.logger {}
@Component
class TossPaymentClient(
private val objectMapper: ObjectMapper,
tossPaymentClientBuilder: RestClient.Builder,
) {
companion object {
private const val CONFIRM_URL: String = "/v1/payments/confirm"
private const val CANCEL_URL: String = "/v1/payments/{paymentKey}/cancel"
}
private val tossPaymentClient: RestClient = tossPaymentClientBuilder.build()
fun confirm(paymentRequest: PaymentApproveRequest): PaymentApproveResponse {
logPaymentInfo(paymentRequest)
return tossPaymentClient.post()
.uri(CONFIRM_URL)
.contentType(MediaType.APPLICATION_JSON)
.body(paymentRequest)
.retrieve()
.onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res, "confirm") }
)
.body(PaymentApproveResponse::class.java)
?: run {
log.error { "[TossPaymentClient] 응답 변환 오류" }
throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
fun cancel(cancelRequest: PaymentCancelRequest): PaymentCancelResponse {
logPaymentCancelInfo(cancelRequest)
val param = Map.of<String, String>("cancelReason", cancelRequest.cancelReason)
return tossPaymentClient.post()
.uri(CANCEL_URL, cancelRequest.paymentKey)
.contentType(MediaType.APPLICATION_JSON)
.body(param)
.retrieve()
.onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res, "cancel") }
)
.body(PaymentCancelResponse::class.java)
?: run {
log.error { "[TossPaymentClient] 응답 변환 오류" }
throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
private fun logPaymentInfo(paymentRequest: PaymentApproveRequest) {
log.info {
"[TossPaymentClient.confirm] 결제 승인 요청: request: $paymentRequest"
}
}
private fun logPaymentCancelInfo(cancelRequest: PaymentCancelRequest) {
log.info {
"[TossPaymentClient.cancel] 결제 취소 요청: request: $cancelRequest"
}
}
private fun handlePaymentError(
res: ClientHttpResponse,
calledBy: String
): Nothing {
getErrorCodeByHttpStatus(res.statusCode).also {
logTossPaymentError(res, calledBy)
throw PaymentException(it)
}
}
private fun logTossPaymentError(res: ClientHttpResponse, calledBy: String): TossPaymentErrorResponse {
val body = res.body
val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java)
body.close()
log.error { "[TossPaymentClient.$calledBy] 요청 실패: response: $errorResponse" }
return errorResponse
}
private fun getErrorCodeByHttpStatus(statusCode: HttpStatusCode): PaymentErrorCode {
if (statusCode.is4xxClientError) {
return PaymentErrorCode.PAYMENT_CLIENT_ERROR
}
return PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
}

View File

@ -1,22 +0,0 @@
package roomescape.payment.infrastructure.client
import java.time.OffsetDateTime
data class TossPaymentErrorResponse(
val code: String,
val message: String
)
data class PaymentApproveRequest(
val paymentKey: String,
val orderId: String,
val amount: Long,
val paymentType: String
)
data class PaymentApproveResponse(
val paymentKey: String,
val orderId: String,
val totalAmount: Long,
val approvedAt: OffsetDateTime
)

View File

@ -1,24 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import jakarta.persistence.Entity
import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
@Entity
@Table(name = "canceled_payment1")
class CanceledPaymentEntityV2(
id: Long,
val paymentId: Long,
val requestedAt: LocalDateTime,
val canceledAt: OffsetDateTime,
val canceledBy: Long,
val cancelReason: String,
val cancelAmount: Int,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int,
val easypayDiscountAmount: Int,
) : PersistableBaseEntity(id)

View File

@ -1,7 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface CanceledPaymentRepositoryV2 : JpaRepository<CanceledPaymentEntityV2, Long> {
fun findByPaymentId(paymentId: Long): CanceledPaymentEntityV2?
}

View File

@ -1,38 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.PaymentMethod
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import java.time.OffsetDateTime
@Entity
@Table(name = "payment1")
class PaymentEntityV2(
id: Long,
val reservationId: Long,
val paymentKey: String,
val orderId: String,
val totalAmount: Int,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
@Enumerated(EnumType.STRING)
val type: PaymentType,
@Enumerated(EnumType.STRING)
val method: PaymentMethod,
@Enumerated(EnumType.STRING)
var status: PaymentStatus
) : PersistableBaseEntity(id) {
fun cancel() {
this.status = PaymentStatus.CANCELED
}
}

View File

@ -1,8 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long> {
fun findByReservationId(reservationId: Long): PaymentEntityV2?
}

View File

@ -1,166 +0,0 @@
package roomescape.payment.business
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.implement.PaymentFinder
import roomescape.payment.implement.PaymentWriter
import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class PaymentServiceTest : FunSpec({
val paymentFinder: PaymentFinder = mockk()
val paymentWriter: PaymentWriter = mockk()
val paymentService = PaymentService(paymentFinder, paymentWriter)
context("createPayment") {
val approvedPaymentInfo = PaymentApproveResponse(
paymentKey = "paymentKey",
orderId = "orderId",
totalAmount = 1000L,
approvedAt = OffsetDateTime.now(),
)
val reservation = ReservationFixture.create(id = 1L)
test("정상 응답") {
every {
paymentWriter.create(
paymentKey = approvedPaymentInfo.paymentKey,
orderId = approvedPaymentInfo.orderId,
totalAmount = approvedPaymentInfo.totalAmount,
approvedAt = approvedPaymentInfo.approvedAt,
reservation = reservation
)
} returns PaymentFixture.create(
id = 1L,
orderId = approvedPaymentInfo.orderId,
paymentKey = approvedPaymentInfo.paymentKey,
totalAmount = approvedPaymentInfo.totalAmount,
approvedAt = approvedPaymentInfo.approvedAt,
reservation = reservation
)
val response = paymentService.createPayment(approvedPaymentInfo, reservation)
assertSoftly(response) {
it.id shouldBe 1L
it.paymentKey shouldBe approvedPaymentInfo.paymentKey
it.reservationId shouldBe reservation.id
}
}
}
context("createCanceledPayment(canceledPaymentInfo)") {
val canceledPaymentInfo = PaymentCancelResponse(
cancelStatus = "normal",
cancelReason = "고객 요청",
cancelAmount = 1000L,
canceledAt = OffsetDateTime.now(),
)
val approvedAt = OffsetDateTime.now()
val paymentKey = "paymentKey"
test("CanceledPaymentEntity를 응답") {
every {
paymentWriter.createCanceled(
cancelReason = canceledPaymentInfo.cancelReason,
cancelAmount = canceledPaymentInfo.cancelAmount,
canceledAt = canceledPaymentInfo.canceledAt,
approvedAt = approvedAt,
paymentKey = paymentKey
)
} returns PaymentFixture.createCanceled(
id = 1L,
paymentKey = paymentKey,
cancelAmount = canceledPaymentInfo.cancelAmount
)
val response = paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey)
response.shouldBeInstanceOf<CanceledPaymentEntity>()
response.paymentKey shouldBe paymentKey
}
}
context("createCanceledPayment(reservationId)") {
val reservationId = 1L
test("취소 사유를 '예약 취소'로 하여 PaymentCancelRequest를 응답") {
val payment = PaymentFixture.create(id = 1L, paymentKey = "paymentKey", totalAmount = 1000L)
every {
paymentFinder.findByReservationId(reservationId)
} returns payment
val cancelReasonSlot = slot<String>()
every {
paymentWriter.createCanceled(payment, capture(cancelReasonSlot), any())
} returns PaymentFixture.createCanceled(
id = 1L,
paymentKey = payment.paymentKey,
cancelAmount = payment.totalAmount
)
val response = paymentService.createCanceledPayment(reservationId)
response.shouldBeInstanceOf<PaymentCancelRequest>()
cancelReasonSlot.captured shouldBe "예약 취소"
}
test("결제 정보가 없으면 예외 응답") {
every {
paymentFinder.findByReservationId(reservationId)
} throws PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
shouldThrow<PaymentException> {
paymentService.createCanceledPayment(reservationId)
}.also {
it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
}
}
}
context("updateCanceledTime") {
val paymentKey = "paymentKey"
val canceledAt = OffsetDateTime.of(LocalDate.of(2025, 8, 5), LocalTime.of(10, 0), ZoneOffset.UTC)
test("정상 응답") {
val canceled = PaymentFixture.createCanceled(id = 1L)
every {
paymentFinder.findCanceledByKey(paymentKey)
} returns canceled
paymentService.updateCanceledTime(paymentKey, canceledAt)
canceled.canceledAt shouldBe canceledAt
}
test("결제 취소 정보가 없으면 예외 응답") {
every {
paymentFinder.findCanceledByKey(paymentKey)
} throws PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
shouldThrow<PaymentException> {
paymentService.updateCanceledTime(paymentKey, canceledAt)
}.also {
it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
}
}
}
})

View File

@ -1,93 +0,0 @@
package roomescape.payment.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentRepository
class PaymentFinderTest : FunSpec({
val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk()
val paymentFinder = PaymentFinder(paymentRepository, canceledPaymentRepository)
context("existsPaymentByReservationId") {
val reservationId = 1L
test("결제 정보가 있으면 true를 반환한다.") {
every {
paymentRepository.existsByReservationId(reservationId)
} returns true
paymentFinder.existsPaymentByReservationId(reservationId) shouldBe true
}
test("결제 정보가 없으면 false를 반환한다.") {
every {
paymentRepository.existsByReservationId(reservationId)
} returns false
paymentFinder.existsPaymentByReservationId(reservationId) shouldBe false
}
}
context("findByReservationId") {
val reservationId = 1L
test("결제 정보를 조회한다.") {
every {
paymentRepository.findByReservationId(reservationId)
} returns mockk()
paymentFinder.findByReservationId(reservationId)
verify(exactly = 1) {
paymentRepository.findByReservationId(reservationId)
}
}
test("결제 정보가 없으면 실패한다.") {
every {
paymentRepository.findByReservationId(reservationId)
} returns null
shouldThrow<PaymentException> {
paymentFinder.findByReservationId(reservationId)
}.also {
it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
}
}
}
context("findCanceledByKey") {
val paymentKey = "paymentKey"
test("결제 취소 정보를 조회한다.") {
every {
canceledPaymentRepository.findByPaymentKey(paymentKey)
} returns mockk()
paymentFinder.findCanceledByKey(paymentKey)
verify(exactly = 1) {
canceledPaymentRepository.findByPaymentKey(paymentKey)
}
}
test("결제 취소 정보가 없으면 실패한다.") {
every {
canceledPaymentRepository.findByPaymentKey(paymentKey)
} returns null
shouldThrow<PaymentException> {
paymentFinder.findCanceledByKey(paymentKey)
}.also {
it.errorCode shouldBe PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND
}
}
}
})

View File

@ -1,121 +0,0 @@
package roomescape.payment.implement
import com.ninjasquad.springmockk.MockkClear
import com.ninjasquad.springmockk.clear
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.date.after
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import roomescape.util.TsidFactory
import java.time.OffsetDateTime
class PaymentWriterTest : FunSpec({
val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk()
val paymentWriter = PaymentWriter(paymentRepository, canceledPaymentRepository, TsidFactory)
val paymentKey = "paymentKey"
val orderId = "orderId"
val totalAmount = 1000L
val approvedAt = OffsetDateTime.now()
context("create") {
val reservation: ReservationEntity = ReservationFixture.create(id = 1L)
test("결제 정보를 저장한다.") {
val slot = slot<PaymentEntity>()
every {
paymentRepository.save(capture(slot))
} returns mockk()
paymentWriter.create(paymentKey, orderId, totalAmount, approvedAt, reservation)
verify(exactly = 1) {
paymentRepository.save(any())
}
slot.captured.also {
it.paymentKey shouldBe paymentKey
it.orderId shouldBe orderId
it.totalAmount shouldBe totalAmount
it.approvedAt shouldBe approvedAt
}
paymentRepository.clear(MockkClear.AFTER)
}
}
context("createCanceled") {
val cancelReason = "고객 요청"
val canceledAt = OffsetDateTime.now()
afterTest {
clearMocks(paymentRepository)
clearMocks(canceledPaymentRepository)
}
test("PaymentEntity를 받아 저장한다.") {
val payment: PaymentEntity = PaymentFixture.create(id = 1L)
val slot = slot<CanceledPaymentEntity>()
every {
canceledPaymentRepository.save(capture(slot))
} returns mockk()
every {
paymentRepository.deleteByPaymentKey(paymentKey)
} returns Unit
paymentWriter.createCanceled(payment, cancelReason, canceledAt)
verify(exactly = 1) { canceledPaymentRepository.save(any()) }
verify(exactly = 1) { paymentRepository.deleteByPaymentKey(any()) }
slot.captured.also {
it.paymentKey shouldBe payment.paymentKey
it.cancelAmount shouldBe payment.totalAmount
it.approvedAt shouldBe payment.approvedAt
}
}
test("취소 정보를 받아 저장한다.") {
val slot = slot<CanceledPaymentEntity>()
every {
canceledPaymentRepository.save(capture(slot))
} returns mockk()
every {
paymentRepository.deleteByPaymentKey(paymentKey)
} returns Unit
paymentWriter.createCanceled(
cancelReason = cancelReason,
cancelAmount = totalAmount,
canceledAt = canceledAt,
approvedAt = approvedAt,
paymentKey = paymentKey
)
verify(exactly = 1) { canceledPaymentRepository.save(any()) }
verify(exactly = 1) { paymentRepository.deleteByPaymentKey(any()) }
slot.captured.also {
it.paymentKey shouldBe paymentKey
it.cancelAmount shouldBe totalAmount
it.approvedAt shouldBe approvedAt
}
}
}
})

View File

@ -1,34 +0,0 @@
package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import roomescape.payment.web.PaymentCancelResponse
class PaymentCancelResponseDeserializerTest : StringSpec({
val objectMapper: ObjectMapper = jacksonObjectMapper().registerModule(
SimpleModule().addDeserializer(
PaymentCancelResponse::class.java,
PaymentCancelResponseDeserializer()
)
)
"결제 취소 응답을 역직렬화하여 PaymentCancelResponse 객체를 생성한다" {
val cancelResponseJson: String = SampleTossPaymentConst.cancelJson
val cancelResponse: PaymentCancelResponse = objectMapper.readValue(
cancelResponseJson,
PaymentCancelResponse::class.java
)
assertSoftly(cancelResponse) {
cancelResponse.cancelStatus shouldBe "DONE"
cancelResponse.cancelReason shouldBe SampleTossPaymentConst.cancelReason
cancelResponse.cancelAmount shouldBe SampleTossPaymentConst.amount
cancelResponse.canceledAt.toString() shouldBe "2024-02-13T12:20:23+09:00"
}
}
})

View File

@ -1,146 +0,0 @@
package roomescape.payment.infrastructure.client
import com.ninjasquad.springmockk.MockkBean
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.ResponseActions
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
@RestClientTest(TossPaymentClient::class)
@MockkBean(JpaMetamodelMappingContext::class)
class TossPaymentClientTest(
@Autowired val client: TossPaymentClient,
@Autowired val mockServer: MockRestServiceServer
) : FunSpec() {
init {
context("결제 승인 요청") {
fun commonAction(): ResponseActions = mockServer.expect {
requestTo("/v1/payments/confirm")
}.andExpect {
method(HttpMethod.POST)
}.andExpect {
content().contentType(MediaType.APPLICATION_JSON)
}.andExpect {
content().json(SampleTossPaymentConst.paymentRequestJson)
}
test("성공 응답") {
commonAction().andRespond {
withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.confirmJson)
.createResponse(it)
}
val paymentRequest = SampleTossPaymentConst.paymentRequest
val paymentResponse: PaymentApproveResponse = client.confirm(paymentRequest)
assertSoftly(paymentResponse) {
this.paymentKey shouldBe paymentRequest.paymentKey
this.orderId shouldBe paymentRequest.orderId
this.totalAmount shouldBe paymentRequest.amount
}
}
context("실패 응답") {
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
commonAction().andRespond {
withStatus(httpStatus)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it)
}
val paymentRequest = SampleTossPaymentConst.paymentRequest
// then
val exception = shouldThrow<PaymentException> {
client.confirm(paymentRequest)
}
exception.errorCode shouldBe expectedError
}
test("결제 서버에서 4XX 응답 시") {
runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
}
test("결제 서버에서 5XX 응답 시") {
runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
}
context("결제 취소 요청") {
fun commonAction(): ResponseActions = mockServer.expect {
requestTo("/v1/payments/${SampleTossPaymentConst.paymentKey}/cancel")
}.andExpect {
method(HttpMethod.POST)
}.andExpect {
content().contentType(MediaType.APPLICATION_JSON)
}.andExpect {
content().json(SampleTossPaymentConst.cancelRequestJson)
}
test("성공 응답") {
commonAction().andRespond {
withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.cancelJson)
.createResponse(it)
}
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest
val cancelResponse: PaymentCancelResponse = client.cancel(cancelRequest)
assertSoftly(cancelResponse) {
this.cancelStatus shouldBe "DONE"
this.cancelReason shouldBe cancelRequest.cancelReason
this.cancelAmount shouldBe cancelRequest.amount
}
}
context("실패 응답") {
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
commonAction().andRespond {
withStatus(httpStatus)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it)
}
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest
val exception = shouldThrow<PaymentException> {
client.cancel(cancelRequest)
}
exception.errorCode shouldBe expectedError
}
test("결제 서버에서 4XX 응답 시") {
runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
}
test("결제 서버에서 5XX 응답 시") {
runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
}
}
}

View File

@ -1,39 +0,0 @@
package roomescape.payment.infrastructure.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.common.config.next
import roomescape.util.PaymentFixture
import roomescape.util.TsidFactory
import java.util.*
@DataJpaTest(showSql = false)
class CanceledPaymentRepositoryTest(
@Autowired val canceledPaymentRepository: CanceledPaymentRepository,
) : FunSpec() {
init {
context("paymentKey로 CanceledPaymentEntity 조회") {
val paymentKey = "test-payment-key"
beforeTest {
PaymentFixture.createCanceled(id = TsidFactory.next(), paymentKey = paymentKey)
.also { canceledPaymentRepository.save(it) }
}
test("정상 반환") {
canceledPaymentRepository.findByPaymentKey(paymentKey)?.let {
assertSoftly(it) {
this.paymentKey shouldBe paymentKey
}
} ?: throw AssertionError("Unexpected null value")
}
test("null 반환") {
canceledPaymentRepository.findByPaymentKey(UUID.randomUUID().toString())
.also { it shouldBe null }
}
}
}
}

View File

@ -1,56 +0,0 @@
package roomescape.payment.infrastructure.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.common.config.next
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import roomescape.util.TsidFactory
@DataJpaTest(showSql = false)
class PaymentRepositoryTest(
@Autowired val paymentRepository: PaymentRepository,
@Autowired val entityManager: EntityManager
) : FunSpec() {
lateinit var reservation: ReservationEntity
init {
context("existsByReservationId") {
beforeTest {
reservation = setupReservation()
PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
}
test("true") {
paymentRepository.existsByReservationId(reservation.id!!)
.also { it shouldBe true }
}
test("false") {
paymentRepository.existsByReservationId(reservation.id!! + 1L)
.also { it shouldBe false }
}
}
}
private fun setupReservation(): ReservationEntity {
return ReservationFixture.create(
id = TsidFactory.next()
).also {
entityManager.persist(it.member)
entityManager.persist(it.theme)
entityManager.persist(it.time)
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
}
}