Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: 결제 리팩토링 #113

Merged
merged 39 commits into from
Apr 14, 2024
Merged

Conversation

hgo641
Copy link
Contributor

@hgo641 hgo641 commented Mar 6, 2024

📌 관련 이슈

📁 작업 설명

저번 PR에서 얘기 나왔던 대로, OrderPayment 테이블에서 주문 상태(status) 를 저장하는 방식으로 구현했습니다.
그래서 OrderService에서 save()하는 순간에 OrderPayment 객체를 미리 만들게 했는데... 괜찮나요? 이 때는 tossPaymentId가 없는 상태로 생깁니다!

기타

  • OrderPayment 확정되면 테스트 추가 & Order에 있는 status 지우기

Copy link

github-actions bot commented Mar 6, 2024

Test Results

 59 files  + 3   59 suites  +3   23s ⏱️ -1s
283 tests + 9  283 ✅ + 9  0 💤 ±0  0 ❌ ±0 
392 runs  +17  392 ✅ +17  0 💤 ±0  0 ❌ ±0 

Results for commit b43b5b8. ± Comparison against base commit 1dfa139.

This pull request removes 2 and adds 11 tests. Note that renamed tests count towards both.
com.petqua.domain.order.OrderTest ‑ 결제 처리 시 결제를 할 수 없다면 예외를 던진다
com.petqua.domain.order.OrderTest ‑ 결제 처리를 한다
com.petqua.domain.order.OrderPaymentRepositoryTest ‑ Then: 예외를 던진다
com.petqua.domain.order.OrderPaymentRepositoryTest ‑ Then: 최신 OrderPayment 를 반환한다
com.petqua.domain.order.OrderPaymentTest ‑ 결제 시도시 결제가 가능한 상태가 아니라면 예외를 던진다
com.petqua.domain.order.OrderPaymentTest ‑ 결제를 진행한다
com.petqua.domain.order.OrderPaymentTest ‑ 주문을 취소한다
com.petqua.presentation.order.OrderControllerTest ‑ Then: 배송번호는 두 개 생성된다
com.petqua.presentation.order.OrderControllerTest ‑ Then: 배송번호는 한 개 생성된다
com.petqua.presentation.order.OrderControllerTest ‑ Then: 배송번호는 한 개만 생성된다
com.petqua.presentation.order.OrderControllerTest ‑ Then: 배송비와 배송 방법이 정상적으로 입력된다
com.petqua.presentation.order.OrderControllerTest ‑ Then: 배송비와 배송 방법이 정상적으로 저장된다
…

♻️ This comment has been updated with latest results.

@hgo641 hgo641 marked this pull request as ready for review March 7, 2024 08:00
@Combi153 Combi153 assigned Combi153 and hgo641 and unassigned Combi153 Mar 8, 2024
Copy link
Contributor

@Combi153 Combi153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동시성 이슈도 고려하고, 코드도 리팩터링 하느라 많이 힘드셨을 것 같습니다.
특히 Money 정말 감사합니다. 😀 😀 😀 😀 😀
고생 많으셨습니다!!!!

커멘트 남겼으니 확인 부탁드립니다!

Copy link
Member

@TaeyeonRoyce TaeyeonRoyce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

복잡하고 양이 많은 만큼 코멘트 남길 부분들이 조금 많네요..!
우선 Order관련해서만 코멘트 남겨두었는데 확인해주세요!

val deliveryMethod = storeDeliveryMethod.second
products.first().getDeliveryFee(deliveryMethod).toInt()
}.sum()
private fun getOrderProductsFrom(command: SaveOrderCommand): OrderProducts {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적으로 SaveOrderCommand를 파라미터로 넘기면서 사용하고 있는데요.
SaveOrderCommand객체보다 내부 값을 넘기는 식으로 활용하는게 어떨까요.
save()처럼 public하게 사용하도록 열어둔 메서드에서는 관심사항이 캡슐화된 객체를 받는건 좋지만, 내부에서도 이 객체를 계속 사용하면 로직이 더 헷갈리는 것 같아요.

Suggested change
private fun getOrderProductsFrom(command: SaveOrderCommand): OrderProducts {
private fun getOrderProductsFrom(orderProductIds: List<Long>): OrderProducts {

val shippingAddress = shippingAddressRepository.findByIdOrThrow(command.shippingAddressId) {
ShippingAddressException(NOT_FOUND_SHIPPING_ADDRESS)
}
val productSnapshots =
productSnapshotRepository.findAllByProductIdIn(orderProducts.productIds).associateBy { it.productId }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

productSnapshot도 계속 추가되는 구조라 가장 최신의 snapshot을 조회해야 합니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 이거 JpaRepository에서 조회할 때 쿼리단에서 아예 최신의 snapshot만 조회해올지, repository에선 모든 snapshot을 조회해온 후에 application에서 최신 snapshot을 추출할지 고민하다가 적용하는 것을 깜박했네요...............ㅎㅎ 죄송합니다

JpaRepository에서 groupBy로 최신 snapshot만 가져오게 했는데 이거 괜찮으신가요?!

@Query("SELECT ps FROM ProductSnapshot ps WHERE ps.id IN (SELECT MAX(ps2.id) FROM ProductSnapshot ps2 WHERE ps2.productId IN :productIds GROUP BY ps2.productId)")
    fun findAllByProductIdIn(productIds: List<Long>): List<ProductSnapshot>

// TODO: TODO 재고 검증
private fun validateProductSnapshots(orderProducts: OrderProducts, productSnapshots: Map<Long, ProductSnapshot>) {
orderProducts.products.forEach { product ->
productSnapshots[product.id]?.takeIf { it.isFrom(product) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFrom보다 더 좋은 이름은 없을까요...
생성 메서드에서 from을 쓰다보니 헷갈리네요.

orderNumber
),
productSnapshot = productSnapshot,
storeName = storesById[productSnapshot.storeId]?.name ?: throw OrderException(PRODUCT_NOT_FOUND),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여긴 Store관련 Exception을 던져야 할 것 같아요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrderException(STORE_NOT_FOUND) 괜찮으신가용

Comment on lines 142 to 146
fun validate(command: SaveOrderCommand) {
validateProducts(command)
validateProductOptions(command)
validateOrderPrices(command)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가로 메서드에 어떤 걸 검증하는지 나타내면 좋을 것 같아요.

Comment on lines 198 to 200
private fun Pair<Long, DeliveryMethod>.calculateDeliveryGroupFee(products: List<Product>): Int {
return products.first().getDeliveryFee(second).value.toInt()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메서드 위치도 getTotalDeliveryFee 아래에 있으면 더 좋을 것 같아요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위 코멘트 전부 반영해서 OrderProduct 검증 로직 분리했습니다! 👍

1ee8eeb

Comment on lines 84 to 87
shippingAddressId ?: run {
orderProductCommands.find { it.deliveryMethod != PICK_UP }
?: throw OrderException(EMPTY_SHIPPING_ADDRESS)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shippingAddressId 가 null 일 때, 주문 상품 중 PICK_UP이 아닌 상품이 null 이라면, 예외를 던지고 있는 것으로 보여요.

  • PICK_UP 이 아닌 상품이 null 이라는 건 모든 주문 상품이 PICK_UP 이라는 의미로 이해했는데요. PICK_UP 이 아닌 상품이 null 일 때 예외를 던지는 이유가 무엇인가요? 혹시 PICK_UP 인 상품이 없을 때(null 일 때) 예외를 던져야 하는 건 아닐까요? 아직 기획이 명확하지 않은 부분이어서 개발이 덜 된 걸까요?
  • 가독성이 너무 떨어져요ㅠㅠ 엘비스 연산자를 중첩해서 사용하는 건 지양하는 건 어떨까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 PICK_UP 인 상품이 없을 때(null 일 때) 예외를 던져야 하는 건 아닐까요?

맞습니다.........ㅋ큐ㅠ 반대로 작성했네요 수정했습니다! 감사합니다!
59ee3fc

Copy link
Member

@TaeyeonRoyce TaeyeonRoyce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!
리팩토링이 양도 많고 로직도 복잡해서 쉽지 않네요...

수정하신 부분에 대해 코멘트 남겨두었습니다! 확인 부탁드려요!!

Copy link
Contributor

@Combi153 Combi153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로직이 잘 작동하는지 궁금해서 테스트를 짜보았습니다. 마침 Controller 테스트가 없길래 진행했어요.

작성한 코드를 커멘트에 쓸지, 커밋을 해 드릴지 고민했는데, 일단 커멘트에 써 둘게요. 원하신다면 커밋 요청해주세요!

테스트 코드에서는 통과해야 하지만 통과하지 않는 로직, 예외를 던져야 하지만 예외를 던지지 않는 로직이 있었습니다.

그중에서는 지난 PR에서 제가 실수한 부분도 있었네요.

그 테스트들은 다음과 같아요.

  1. 하나의 상점에서 여러 배송 조건으로 두 개의 상품을 주문하는 경우 -> 예외가 발생해야 함
  2. 하나의 상점에서 직접 수령으로 여러 개의 상품을 주문하는 경우 -> 실패함 OrderRepository.findByOrderNumberOrThrow() 메서드가 잘못 구현되어 있음
  3. 주문한 상품과 실제 상품의 본래 가격(original price)가 다른 경우 -> 예외가 발생해야 함

이중 2번은 제가 OrderRepository.findByOrderNumberOrThrow() 구현 시 하나의 주문번호로 여러 개의 주문이 생성될 수 있다는 것을 간과했기 때문에 실패합니다. List 로 반환 타입을 변경해야 할 것 같아요. 이 부분을 제가 PR 하나 새로 파서 진행해서 머지를 해드릴지, 아니면 홍고가 직접 이 PR에서 하시는 게 편하실지 말씀해주세요! 충돌이 까다로울까 염려스럽네요...!

controller test

package com.petqua.presentation.order

import com.petqua.application.order.dto.SaveOrderResponse
import com.petqua.common.domain.Money
import com.petqua.common.exception.ExceptionResponse
import com.petqua.domain.delivery.DeliveryMethod.COMMON
import com.petqua.domain.delivery.DeliveryMethod.PICK_UP
import com.petqua.domain.order.OrderNumber
import com.petqua.domain.order.OrderRepository
import com.petqua.domain.order.ShippingAddressRepository
import com.petqua.domain.order.findByOrderNumberOrThrow
import com.petqua.domain.product.ProductRepository
import com.petqua.domain.product.ProductSnapshot
import com.petqua.domain.product.ProductSnapshotRepository
import com.petqua.domain.product.option.ProductOptionRepository
import com.petqua.domain.product.option.Sex.FEMALE
import com.petqua.domain.product.option.Sex.MALE
import com.petqua.domain.store.StoreRepository
import com.petqua.exception.order.OrderExceptionType.ORDER_PRICE_NOT_MATCH
import com.petqua.exception.order.OrderExceptionType.PRODUCT_NOT_FOUND
import com.petqua.exception.order.ShippingAddressExceptionType.NOT_FOUND_SHIPPING_ADDRESS
import com.petqua.exception.product.ProductExceptionType.INVALID_PRODUCT_OPTION
import com.petqua.test.ApiTestConfig
import com.petqua.test.fixture.orderProductRequest
import com.petqua.test.fixture.product
import com.petqua.test.fixture.productOption
import com.petqua.test.fixture.saveOrderRequest
import com.petqua.test.fixture.shippingAddress
import com.petqua.test.fixture.store
import io.kotest.assertions.assertSoftly
import io.kotest.inspectors.forAll
import io.kotest.matchers.shouldBe
import org.springframework.http.HttpStatus.BAD_REQUEST
import java.math.BigDecimal.ONE
import java.math.BigDecimal.ZERO
import kotlin.Long.Companion.MIN_VALUE

class OrderControllerTest(
    private val orderRepository: OrderRepository,
    private val productRepository: ProductRepository,
    private val storeRepository: StoreRepository,
    private val productOptionRepository: ProductOptionRepository,
    private val productSnapshotRepository: ProductSnapshotRepository,
    private val shippingAddressRepository: ShippingAddressRepository,
) : ApiTestConfig() {

    init {
        Given("주문을 할 때") {
            val accessToken = signInAsMember().accessToken
            val memberId = getMemberIdByAccessToken(accessToken)

            val storeA = storeRepository.save(
                store(
                    name = "storeA"
                )
            )
            val storeB = storeRepository.save(
                store(
                    name = "storeB"
                )
            )

            val productA1 = productRepository.save(
                product(
                    storeId = storeA.id,
                    name = "1",
                    pickUpDeliveryFee = ZERO,
                    commonDeliveryFee = 3000.toBigDecimal(),
                    safeDeliveryFee = 5000.toBigDecimal(),
                )
            )
            val productA2 = productRepository.save(
                product(
                    storeId = storeA.id,
                    name = "2",
                    pickUpDeliveryFee = ZERO,
                    commonDeliveryFee = 3000.toBigDecimal(),
                    safeDeliveryFee = 5000.toBigDecimal(),
                )
            )
            val productB1 = productRepository.save(
                product(
                    storeId = storeB.id,
                    name = "1",
                    pickUpDeliveryFee = ZERO,
                    commonDeliveryFee = 3000.toBigDecimal(),
                )
            )

            val productOptionA1 = productOptionRepository.save(
                productOption(
                    productId = productA1.id,
                    sex = FEMALE,
                )
            )
            val productOptionA2 = productOptionRepository.save(
                productOption(
                    productId = productA2.id,
                    sex = MALE,
                )
            )
            val productOptionB1 = productOptionRepository.save(
                productOption(
                    productId = productB1.id,
                    sex = MALE,
                )
            )

            productSnapshotRepository.save(ProductSnapshot.from(productA1))
            productSnapshotRepository.save(ProductSnapshot.from(productA2))
            productSnapshotRepository.save(ProductSnapshot.from(productB1))

            val shippingAddress = shippingAddressRepository.save(
                shippingAddress(
                    memberId = memberId
                )
            )

            When("하나의 상점에서 일반 배송 조건으로 두 개의 상품을 주문한다면") {
                val productOrderA1 = orderProductRequest(
                    product = productA1,
                    productOption = productOptionA1,
                    quantity = 1,
                    sex = FEMALE,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON
                )
                val productOrderA2 = orderProductRequest(
                    product = productA2,
                    productOption = productOptionA2,
                    quantity = 1,
                    sex = MALE,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                    productOrderA2,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + productA2.discountPrice + Money.from(3000),
                )

                requestSaveOrder(request, accessToken)

                Then("배송번호는 한 개만 생성된다") {
                    val orders = orderRepository.findAll()

                    orders.distinctBy { it.orderProduct.shippingNumber }.size shouldBe 1
                }
            }

            When("하나의 상점에서 여러 배송 조건으로 두 개의 상품을 주문한다면") {
                val productOrderA1 = orderProductRequest(
                    product = productA1,
                    productOption = productOptionA1,
                    quantity = 1,
                    sex = FEMALE,
                    deliveryFee = Money.from(ZERO),
                    deliveryMethod = PICK_UP
                )
                val productOrderA2 = orderProductRequest(
                    product = productA2,
                    productOption = productOptionA2,
                    quantity = 1,
                    sex = MALE,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                    productOrderA2,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + productA2.discountPrice + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외가 발생한다") {
                    // 한 업체에서는 같은 배송 방법으로만 주문 가능. 다른 방법으로 주문할 때에는 다시 새로 구매.
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                    }
                }
            }

            When("하나의 상점에서 직접 수령으로 한 개의 상품을 주문한다면") {
                val productOrderA1 = orderProductRequest(
                    product = productA1,
                    productOption = productOptionA1,
                    quantity = 1,
                    sex = FEMALE,
                    deliveryFee = Money.from(ZERO),
                    deliveryMethod = PICK_UP
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice,
                )

                requestSaveOrder(request, accessToken)

                Then("배송비와 배송 방법이 정상적으로 저장된다") {
                    val orders = orderRepository.findAll()

                    orders.map { it.orderProduct }.forAll {
                        it.deliveryMethod shouldBe PICK_UP
                        it.deliveryFee shouldBe Money.from(ZERO)
                    }
                }

                Then("배송번호는 한 개 생성된다") {
                    val orders = orderRepository.findAll()

                    orders.distinctBy { it.orderProduct.shippingNumber }.size shouldBe 1
                }
            }

            When("하나의 상점에서 직접 수령으로 여러 개의 상품을 주문한다면") {
                val productOrderA1 = orderProductRequest(
                    product = productA1,
                    productOption = productOptionA1,
                    quantity = 1,
                    sex = FEMALE,
                    deliveryFee = Money.from(ZERO),
                    deliveryMethod = PICK_UP
                )
                val productOrderA2 = orderProductRequest(
                    product = productA2,
                    productOption = productOptionA2,
                    quantity = 1,
                    sex = MALE,
                    deliveryFee = Money.from(ZERO),
                    deliveryMethod = PICK_UP
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                    productOrderA2,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + productA2.discountPrice,
                )

                val response = requestSaveOrder(request, accessToken)

                Then("배송비와 배송 방법이 정상적으로 입력된다") {
                    val saveOrderResponse = response.`as`(SaveOrderResponse::class.java)
                    val order = orderRepository.findByOrderNumberOrThrow(OrderNumber(saveOrderResponse.orderId))

                    order.orderProduct.deliveryMethod shouldBe PICK_UP
                    order.orderProduct.deliveryFee shouldBe Money.from(ZERO)
                }
            }

            When("주문한 상품이 존재하지 않으면") {
                val orderProductRequests = listOf(
                    orderProductRequest(
                        productId = MIN_VALUE,
                        storeId = storeA.id,
                    )
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = Money.from(ONE),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe PRODUCT_NOT_FOUND.errorMessage()
                    }
                }
            }

            When("주문한 상품의 옵션이 존재하지 않는다면") {
                val productOrderA1 = orderProductRequest(
                    product = productA1,
                    productOption = productOptionA1,
                    quantity = 1,
                    sex = MALE,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe INVALID_PRODUCT_OPTION.errorMessage()
                    }
                }
            }

            When("주문 배송 정보가 존재하지 않는다면") {
                val productOrderA1 = orderProductRequest(
                    product = productA1,
                    productOption = productOptionA1,
                    quantity = 1,
                    sex = FEMALE,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = MIN_VALUE,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe NOT_FOUND_SHIPPING_ADDRESS.errorMessage()
                    }
                }
            }

            When("주문한 상품과 실제 상품의 본래 가격이 다르다면") {
                val productOrderA1 = orderProductRequest(
                    productId = productA1.id,
                    storeId = productA1.storeId,
                    quantity = 1,
                    originalPrice = productA1.price + 1,
                    discountRate = productA1.discountRate,
                    discountPrice = productA1.discountPrice,
                    orderPrice = productA1.discountPrice,
                    sex = FEMALE.name,
                    additionalPrice = productOptionA1.additionalPrice,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON.name
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    // 실패
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe ORDER_PRICE_NOT_MATCH.errorMessage()
                    }
                }
            }

            When("주문한 상품과 실제 상품의 할인 가격이 다르다면") {
                val productOrderA1 = orderProductRequest(
                    productId = productA1.id,
                    storeId = productA1.storeId,
                    quantity = 1,
                    originalPrice = productA1.price,
                    discountRate = productA1.discountRate,
                    discountPrice = productA1.discountPrice + 1,
                    orderPrice = productA1.discountPrice + 1,
                    sex = FEMALE.name,
                    additionalPrice = productOptionA1.additionalPrice,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON.name
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + 1 + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe ORDER_PRICE_NOT_MATCH.errorMessage()
                    }
                }
            }

            When("주문한 상품과 실제 상품의 옵션 추가 가격이 다르다면") {
                val productOrderA1 = orderProductRequest(
                    productId = productA1.id,
                    storeId = productA1.storeId,
                    quantity = 1,
                    originalPrice = productA1.price,
                    discountRate = productA1.discountRate,
                    discountPrice = productA1.discountPrice,
                    orderPrice = productA1.discountPrice,
                    sex = FEMALE.name,
                    additionalPrice = productOptionA1.additionalPrice + 1,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON.name
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe INVALID_PRODUCT_OPTION.errorMessage()
                    }
                }
            }

            When("주문한 상품 가격의 합과 주문 가격이 다르다면") {
                val productOrderA1 = orderProductRequest(
                    productId = productA1.id,
                    storeId = productA1.storeId,
                    quantity = 1,
                    originalPrice = productA1.price,
                    discountRate = productA1.discountRate,
                    discountPrice = productA1.discountPrice,
                    orderPrice = productA1.discountPrice,
                    sex = FEMALE.name,
                    additionalPrice = productOptionA1.additionalPrice,
                    deliveryFee = Money.from(3000),
                    deliveryMethod = COMMON.name
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + 1 + Money.from(3000),
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe ORDER_PRICE_NOT_MATCH.errorMessage()
                    }
                }
            }

            When("주문한 상품의 배송비가 책정된 금액과 다르다면") {
                val productOrderA1 = orderProductRequest(
                    productId = productA1.id,
                    storeId = productA1.storeId,
                    quantity = 1,
                    originalPrice = productA1.price,
                    discountRate = productA1.discountRate,
                    discountPrice = productA1.discountPrice,
                    orderPrice = productA1.discountPrice,
                    sex = FEMALE.name,
                    additionalPrice = productOptionA1.additionalPrice,
                    deliveryFee = Money.from(3000) + 1,
                    deliveryMethod = COMMON.name
                )

                val orderProductRequests = listOf(
                    productOrderA1,
                )

                val request = saveOrderRequest(
                    shippingAddressId = shippingAddress.id,
                    shippingRequest = "부재 시 경비실에 맡겨주세요.",
                    orderProductRequests = orderProductRequests,
                    totalAmount = productA1.discountPrice + Money.from(3000) + 1,
                )

                val response = requestSaveOrder(request, accessToken)

                Then("예외를 응답한다") {
                    val errorResponse = response.`as`(ExceptionResponse::class.java)

                    assertSoftly(response) {
                        statusCode shouldBe BAD_REQUEST.value()
                        errorResponse.message shouldBe ORDER_PRICE_NOT_MATCH.errorMessage()
                    }
                }
            }
        }
    }
}

step

package com.petqua.presentation.order

import com.petqua.presentation.order.dto.SaveOrderRequest
import io.restassured.module.kotlin.extensions.Extract
import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When
import io.restassured.response.Response
import org.springframework.http.MediaType.APPLICATION_JSON_VALUE

fun requestSaveOrder(
    request: SaveOrderRequest,
    accessToken: String,
): Response {
    return Given {
        log().all()
        body(request)
        auth().preemptive().oauth2(accessToken)
        contentType(APPLICATION_JSON_VALUE)
    } When {
        post("/orders")
    } Then {
        log().all()
    } Extract {
        response()
    }
}

fixtures

package com.petqua.test.fixture

import com.petqua.application.order.dto.OrderProductCommand
import com.petqua.application.order.dto.SaveOrderCommand
import com.petqua.common.domain.Money
import com.petqua.domain.delivery.DeliveryMethod
import com.petqua.domain.delivery.DeliveryMethod.COMMON
import com.petqua.domain.delivery.DeliveryMethod.SAFETY
import com.petqua.domain.order.Order
import com.petqua.domain.order.OrderName
import com.petqua.domain.order.OrderNumber
import com.petqua.domain.order.OrderProduct
import com.petqua.domain.order.OrderShippingAddress
import com.petqua.domain.order.OrderStatus
import com.petqua.domain.order.ShippingNumber
import com.petqua.domain.product.Product
import com.petqua.domain.product.option.ProductOption
import com.petqua.domain.product.option.Sex
import com.petqua.domain.product.option.Sex.FEMALE
import com.petqua.presentation.order.dto.OrderProductRequest
import com.petqua.presentation.order.dto.SaveOrderRequest
import java.math.BigDecimal
import java.math.BigDecimal.ONE
import java.math.BigDecimal.ZERO

private const val DEFAULT_SCALE = 2

fun order(
    id: Long = 0L,
    memberId: Long = 0L,
    orderNumber: OrderNumber = OrderNumber.from("202402211607020ORDERNUMBER"),
    orderName: OrderName = OrderName("상품1"),
    receiver: String = "receiver",
    phoneNumber: String = "010-1234-5678",
    zipCode: Int = 12345,
    address: String = "서울시 강남구 역삼동 99번길",
    detailAddress: String = "101동 101호",
    requestMessage: String? = null,
    quantity: Int = 1,
    originalPrice: BigDecimal = ONE,
    discountRate: Int = 0,
    discountPrice: BigDecimal = originalPrice,
    deliveryFee: BigDecimal = 3000.toBigDecimal(),
    shippingNumber: ShippingNumber = ShippingNumber(""),
    orderPrice: BigDecimal = discountPrice,
    productId: Long = 0L,
    productName: String = "orderProduct",
    thumbnailUrl: String = "image.url",
    storeId: Long = 0L,
    storeName: String = "storeName",
    deliveryMethod: DeliveryMethod = SAFETY,
    sex: Sex = FEMALE,
    isAbleToCancel: Boolean = true,
    status: OrderStatus = OrderStatus.ORDER_CREATED,
    totalAmount: BigDecimal = orderPrice + deliveryFee,
): Order {
    return Order(
        id = id,
        memberId = memberId,
        orderNumber = orderNumber,
        orderName = orderName,
        orderShippingAddress = orderShippingAddress(
            receiver = receiver,
            phoneNumber = phoneNumber,
            zipCode = zipCode,
            address = address,
            detailAddress = detailAddress,
            requestMessage = requestMessage,
        ),
        orderProduct = orderProduct(
            quantity = quantity,
            originalPrice = originalPrice,
            discountRate = discountRate,
            discountPrice = discountPrice,
            deliveryFee = deliveryFee,
            shippingNumber = shippingNumber,
            orderPrice = orderPrice,
            productId = productId,
            productName = productName,
            thumbnailUrl = thumbnailUrl,
            storeId = storeId,
            storeName = storeName,
            deliveryMethod = deliveryMethod,
            sex = sex,
        ),
        isAbleToCancel = isAbleToCancel,
        status = status,
        totalAmount = Money.from(totalAmount),
    )
}

fun orderShippingAddress(
    receiver: String = "receiver",
    phoneNumber: String = "010-1234-5678",
    zipCode: Int = 12345,
    address: String = "서울시 강남구 역삼동 99번길",
    detailAddress: String = "101동 101호",
    requestMessage: String?,
): OrderShippingAddress {
    return OrderShippingAddress(
        receiver = receiver,
        phoneNumber = phoneNumber,
        zipCode = zipCode,
        address = address,
        detailAddress = detailAddress,
        requestMessage = requestMessage,
    )
}

fun orderProduct(
    quantity: Int = 1,
    originalPrice: BigDecimal = ONE,
    discountRate: Int = 0,
    discountPrice: BigDecimal = originalPrice,
    deliveryFee: BigDecimal = 3000.toBigDecimal(),
    shippingNumber: ShippingNumber = ShippingNumber(""),
    orderPrice: BigDecimal = discountPrice,
    productId: Long = 0L,
    productName: String = "orderProduct",
    thumbnailUrl: String = "image.url",
    storeId: Long = 0L,
    storeName: String = "storeName",
    deliveryMethod: DeliveryMethod = SAFETY,
    sex: Sex = FEMALE,
): OrderProduct {
    return OrderProduct(
        quantity = quantity,
        originalPrice = Money.from(originalPrice),
        discountRate = discountRate,
        discountPrice = Money.from(discountPrice),
        deliveryFee = Money.from(deliveryFee),
        shippingNumber = shippingNumber,
        orderPrice = Money.from(orderPrice),
        productId = productId,
        productName = productName,
        thumbnailUrl = thumbnailUrl,
        storeId = storeId,
        storeName = storeName,
        deliveryMethod = deliveryMethod,
        sex = sex,
    )
}

fun saveOrderCommand(
    memberId: Long = 0L,
    shippingAddressId: Long = 0L,
    shippingRequest: String? = null,
    orderProductCommands: List<OrderProductCommand>,
    totalAmount: BigDecimal = ONE * orderProductCommands.size.toBigDecimal(),
): SaveOrderCommand {
    return SaveOrderCommand(
        memberId = memberId,
        shippingAddressId = shippingAddressId,
        shippingRequest = shippingRequest,
        orderProductCommands = orderProductCommands,
        totalAmount = Money.from(totalAmount),
    )
}

fun orderProductCommand(
    productId: Long = 0L,
    quantity: Int = 1,
    originalPrice: BigDecimal = ONE,
    discountRate: Int = 0,
    discountPrice: BigDecimal = originalPrice,
    orderPrice: BigDecimal = ONE,
    sex: Sex = FEMALE,
    additionalPrice: BigDecimal = ZERO,
    deliveryFee: BigDecimal = 3000.toBigDecimal(),
    deliveryMethod: DeliveryMethod = COMMON,
): OrderProductCommand {
    return OrderProductCommand(
        productId = productId,
        quantity = quantity,
        originalPrice = Money.from(originalPrice),
        discountRate = discountRate,
        discountPrice = Money.from(discountPrice),
        orderPrice = Money.from(orderPrice),
        sex = sex,
        additionalPrice = Money.from(additionalPrice),
        deliveryFee = Money.from(deliveryFee),
        deliveryMethod = deliveryMethod,
    )
}

fun saveOrderRequest(
    shippingAddressId: Long? = null,
    shippingRequest: String? = null,
    orderProductRequests: List<OrderProductRequest>,
    totalAmount: Money,
): SaveOrderRequest {
    return SaveOrderRequest(
        shippingAddressId = shippingAddressId,
        shippingRequest = shippingRequest,
        orderProductRequests = orderProductRequests,
        totalAmount = totalAmount
    )
}

fun orderProductRequest(
    productId: Long,
    storeId: Long,
    quantity: Int = 1,
    originalPrice: Money = Money.from(ONE),
    discountRate: Int = 0,
    discountPrice: Money = originalPrice,
    orderPrice: Money = Money.from(ONE),
    sex: String = FEMALE.name,
    additionalPrice: Money = Money.from(ZERO),
    deliveryFee: Money = Money.from(3000),
    deliveryMethod: String = COMMON.name,
): OrderProductRequest {
    return OrderProductRequest(
        productId = productId,
        storeId = storeId,
        quantity = quantity,
        originalPrice = originalPrice,
        discountRate = discountRate,
        discountPrice = discountPrice,
        orderPrice = orderPrice,
        sex = sex,
        additionalPrice = additionalPrice,
        deliveryFee = deliveryFee,
        deliveryMethod = deliveryMethod
    )
}

fun orderProductRequest(
    product: Product,
    productOption: ProductOption,
    quantity: Int = 1,
    sex: Sex,
    deliveryFee: Money,
    deliveryMethod: DeliveryMethod,
): OrderProductRequest {
    return OrderProductRequest(
        productId = product.id,
        storeId = product.storeId,
        quantity = quantity,
        originalPrice = product.price,
        discountRate = product.discountRate,
        discountPrice = product.discountPrice,
        orderPrice = product.price + productOption.additionalPrice,
        sex = sex.name,
        additionalPrice = productOption.additionalPrice,
        deliveryFee = deliveryFee,
        deliveryMethod = deliveryMethod.name
    )
}

@hgo641
Copy link
Contributor Author

hgo641 commented Apr 8, 2024

@Combi153
꼼꼼한 테스트 감사합니다....!!! 😭😭😭

  1. 하나의 상점에서 여러 배송 조건으로 두 개의 상품을 주문하는 경우 -> 예외가 발생해야 함

-> 바로 주문시에만 하나의 배송 조건으로 주문해야하고, 봉달 목록에서 주문시에는 여러 배송 조건으로 주문하는 게 가능하다고 들었습니다. 바로 주문에서는 애초에 하나의 OrderProduct만 들어오니 여러 배송 조건인가?를 검증할 필요가 없다고 생각했습니다. 그래서 검증 코드를 작성하지 않았는데, 혹시 제가 잘못 생각한 부분이 있을까요?? 주문 규칙 너무 헷갈리네요ㅠㅠ

  1. 주문한 상품과 실제 상품의 본래 가격(original price)가 다른 경우 -> 예외가 발생해야 함

-> 이거는 OrderProductsValidator 에서 OrderProductCommand와 실제 Product의 본래 가격이 동일한 지 검증하는 코드를 추가하겠습니다!! 감사합니다!

이중 2번은 제가 OrderRepository.findByOrderNumberOrThrow() 구현 시 하나의 주문번호로 여러 개의 주문이 생성될 수 있다는 것을 간과했기 때문에 실패합니다. List 로 반환 타입을 변경해야 할 것 같아요. 이 부분을 제가 PR 하나 새로 파서 진행해서 머지를 해드릴지, 아니면 홍고가 직접 이 PR에서 하시는 게 편하실지 말씀해주세요! 충돌이 까다로울까 염려스럽네요...!

혹시 2번 리팩토링은 후추에게 부탁드려도 될까요? PaymentService랑도 연관된 코드라 후추가 작업하시는게 더 빠를 것 같습니다🙇‍♀️🙇‍♀️🙇‍♀️
컨트롤러 테스트 코드도 2번하실 때 같이 커밋해주셔도 될 것 같아요...! 괜찮으신가요??

TaeyeonRoyce and others added 13 commits April 9, 2024 07:40
* feat: 로그아웃 시 회원 데이터 변경 로직 추가

* feat: 로그아웃 로직 추가

* feat: 로그아웃 API 추가

* feat: 블랙리스트 토큰 캐시 스토리지 추가

* feat: 로그아웃 시 사용중인 토큰 블랙리스트 추가 로직 적용

* feat: 인증 요청에 대한 블랙리스트 토큰 조회 로직 추가

* test: Redis cleaner 방식 수정

* refactor: 일관성 있는 method 이름으로 수정

* refactor: 사용 하지 않는 파라미터 제거

* fix: 토큰만 사용 되는 API 요청에 대한 로그아웃 블랙리스트 검증 로직 추가
* feat: Fish entity 설계

* feat: Fish repository 조회 기능 추가

* feat: FishService 조회 기능 추가

* feat: FishController 어종 자동완성 검색 API 추가

* feat: FishTankName 검증 기능 추가

* feat: FishTank 추가

* feat: FishLifeYear 검증 기능 추가

* feat: PetFish 기능 추가

* refactor: 카카오 프로필 받지 않도록 변경

* feat: MemberDetail 추가

* chore: rebase 충돌 해결

* refactor: 어종 이름 자동완성 검색 로직 수정

* refactor: FishTank memberId 필드명 변경

* feat: Member update 메서드 추가

* feat: PetFishSex, TankSize 정적 팩터리 메서드 추가

* feat: PetFish 일급컬렉션 PetFishes 추가

* feat: BannedWord 객체 추가

* feat: NicknameWord 객체 추가

* feat: Nickname 객체 추가

* feat: NicknameGenerator generate 기능 추가

* chore: rebase 충돌 해결

* refactor: 로그인 api 상태코드 변경 - 회원가입이 필요한지 여부를 상태코드로 분기

* chore: rebase 충돌 해결

* chore: rebase 충돌 해결

* refactor: 랜덤 닉네임 설정 시 숫자 생성 정책 반영

* feat: 이름에 금지 단어 포함 여부 검증 기능 추가

* feat: 회원 물생활 프로필 입력 API 추가

* feat: 이름 검증 API 추가

* chore: rebase 충돌 해결

* refactor: 회원가입 토큰 커스텀 헤더 관리 기능 추가, 스웨거 작업

* fix: 예외 처리 테스트 기댓값 알맞게 수정

* chore: rebase 충돌 해결

* fix: 회원 삭제 로직 memberId 로 동작하도록 변경

* refactor: 회원 랜덤 닉네임 생성 시 시도 횟수 제한

* refactor: AuthMember 이름 AuthCredentials 로 변경

* refactor: 회원가입 API, 이름 검증 API 응답 상태코드 변경

* chore: rebase 충돌 해결

* refactor: 불필요한 로직 및 메서드 삭제

* refactor: 랜덤 닉네임 생성 실패 시 예외 코드 변경

* fix: Fish 개수를 통한 검증 로직 수정

* refactor: Fish 자동완성 검색 시 정렬 순서 변경

* fix: 이전 PR에 AuthCredentials, Member에 관한 코드 변경사항 반영

* refactor: 로그인 토큰과 회원가입 토큰을 AuthToken 으로 추상화
Copy link
Member

@TaeyeonRoyce TaeyeonRoyce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 반영한거 확인했습니다!
긴 작업이라 중간에 쌓인 코드(커밋)가 섞여있는것 같아요.
이것만 정리해서 올리시면 될 것 같습니다!

고생많으셨어요!

Copy link
Member

@TaeyeonRoyce TaeyeonRoyce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

OrderException(ORDER_NOT_FOUND)
}
order.validateOwner(command.memberId)
order.validateAmount(command.amount)
val orderGroup = OrderGroup(orders)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrderGroup 좋네여

Copy link
Contributor

@Combi153 Combi153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

긴 시간동안 정말 고생 많으셨습니다 👍
이제 다음 스텝 밟으러 가죠!!

@hgo641 hgo641 merged commit ea96561 into develop Apr 14, 2024
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

refactor: 결제 로직 리팩토링
3 participants