diff --git a/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt b/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt index 40e1c9bf..4878e621 100644 --- a/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt +++ b/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt @@ -6,6 +6,7 @@ import com.petqua.domain.cart.CartProductQuantity import com.petqua.domain.delivery.DeliveryMethod import com.petqua.domain.product.Product import com.petqua.domain.product.option.Sex +import com.petqua.domain.store.Store import io.swagger.v3.oas.annotations.media.Schema import java.math.BigDecimal @@ -51,6 +52,12 @@ data class CartProductWithSupportedOptionResponse( ) val id: Long, + @Schema( + description = "상품 판매점 id", + example = "1" + ) + val storeId: Long, + @Schema( description = "상품 판매점", example = "S아쿠아" @@ -163,6 +170,7 @@ data class CartProductWithSupportedOptionResponse( femaleAdditionalPrice: Money?, ) : this( id = cartProductResponse.id, + storeId = cartProductResponse.storeId, storeName = cartProductResponse.storeName, productId = cartProductResponse.productId, productName = cartProductResponse.productName, @@ -185,6 +193,7 @@ data class CartProductWithSupportedOptionResponse( data class CartProductResponse( val id: Long, + val storeId: Long, val storeName: String, val productId: Long, val productName: String, @@ -205,10 +214,11 @@ data class CartProductResponse( constructor( cartProduct: CartProduct, product: Product?, - storeName: String?, + store: Store?, ) : this( id = cartProduct.id, - storeName = storeName ?: "", + storeId = store?.id ?: 0L, + storeName = store?.name ?: "", productId = product?.id ?: 0L, productName = product?.name ?: "", productThumbnailUrl = product?.thumbnailUrl ?: "", diff --git a/src/main/kotlin/com/petqua/application/order/OrderProductsValidator.kt b/src/main/kotlin/com/petqua/application/order/OrderProductsValidator.kt new file mode 100644 index 00000000..7c6ed21d --- /dev/null +++ b/src/main/kotlin/com/petqua/application/order/OrderProductsValidator.kt @@ -0,0 +1,126 @@ +package com.petqua.application.order + +import com.petqua.application.order.dto.OrderProductCommand +import com.petqua.common.domain.Money +import com.petqua.common.util.throwExceptionWhen +import com.petqua.domain.order.DeliveryGroupKey +import com.petqua.domain.product.Product +import com.petqua.domain.product.option.ProductOption +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType +import com.petqua.exception.product.ProductException +import com.petqua.exception.product.ProductExceptionType + +class OrderProductsValidator( + private val productById: Map, + private val productOptions: Set, + private val products: List, +) { + + constructor(productById: Map, productOptions: Set) : this( + productById, + productOptions, + productById.values.toList() + ) + + fun validate(totalAmount: Money, orderProductCommands: List) { + validateProductsIsExist(orderProductCommands) + validateProductOptionsIsExist(orderProductCommands) + validateProductDetailIsMatching(orderProductCommands) + validateOrderProductPrices(orderProductCommands) + validateTotalAmount(totalAmount, orderProductCommands) + } + + fun validateProductsIsExist(orderProductCommands: List) { + val productCommandIds = orderProductCommands.map { it.productId }.toSet() + val productIds = productById.keys + throwExceptionWhen(productCommandIds != productIds) { OrderException(OrderExceptionType.PRODUCT_NOT_FOUND) } + } + + fun validateProductOptionsIsExist(orderProductCommands: List) { + orderProductCommands.forEach { orderProductCommand -> + val productOption = productOptions.findOptionBy(orderProductCommand.productId) + throwExceptionWhen(!productOption.isSame(orderProductCommand.toProductOption())) { + ProductException(ProductExceptionType.INVALID_PRODUCT_OPTION) + } + } + } + + fun validateProductDetailIsMatching(orderProductCommands: List) { + orderProductCommands.forEach { orderProductCommand -> + val product = productById[orderProductCommand.productId] + ?: throw OrderException(OrderExceptionType.PRODUCT_NOT_FOUND) + throwExceptionWhen(!product.isDetailMatching(orderProductCommand)) { + OrderException(OrderExceptionType.PRODUCT_INFO_NOT_MATCH) + } + } + } + + fun validateOrderProductPrices(orderProductCommands: List) { + orderProductCommands.forEach { orderProductCommand -> + val product = productById[orderProductCommand.productId] + ?: throw OrderException(OrderExceptionType.PRODUCT_NOT_FOUND) + val productOption = productOptions.findOptionBy(orderProductCommand.productId) + validateOrderProductPrice(product, productOption, orderProductCommand) + } + } + + fun validateTotalAmount(inputTotalAmount: Money, orderProductCommands: List) { + val totalDeliveryFee = calculateTotalDeliveryFee(orderProductCommands) + throwExceptionWhen(inputTotalAmount != Money.from(totalDeliveryFee.toBigDecimal() + orderProductCommands.sumOf { it.orderPrice.value })) { + OrderException( + OrderExceptionType.ORDER_TOTAL_PRICE_NOT_MATCH + ) + } + } + + private fun validateOrderProductPrice(product: Product, option: ProductOption, command: OrderProductCommand) { + val expectedOrderPrice = (product.discountPrice + option.additionalPrice) * command.quantity.toBigDecimal() + val expectedDeliveryFee = product.getDeliveryFee(command.deliveryMethod) + if (command.orderPrice != expectedOrderPrice || command.deliveryFee != expectedDeliveryFee) { + throw OrderException(OrderExceptionType.ORDER_TOTAL_PRICE_NOT_MATCH) + } + } + + private fun calculateTotalDeliveryFee(orderProductCommands: List): Int { + return products.groupBy { getDeliveryGroupKey(it, orderProductCommands) } + .map { getDeliveryGroupFee(it) } + .sum() + } + + private fun getDeliveryGroupKey( + product: Product, + orderProductCommands: List + ): DeliveryGroupKey { + val deliveryMethod = orderProductCommands.find { it.productId == product.id }?.deliveryMethod + ?: throw OrderException(OrderExceptionType.PRODUCT_NOT_FOUND) + return DeliveryGroupKey(product.storeId, deliveryMethod) + } + + private fun getDeliveryGroupFee(deliveryGroup: Map.Entry>): Int { + val productOfDeliveryGroup = deliveryGroup.value.first() + val deliveryMethod = deliveryGroup.key.deliveryMethod + return productOfDeliveryGroup.getDeliveryFee(deliveryMethod).value.toInt() + } + + private fun Set.findOptionBy(productId: Long): ProductOption { + return find { it.productId == productId } + ?: throw ProductException(ProductExceptionType.INVALID_PRODUCT_OPTION) + } + + private fun Product.isDetailMatching(orderProductCommand: OrderProductCommand): Boolean { + if (price != orderProductCommand.originalPrice) { + return false + } + if (discountRate != orderProductCommand.discountRate) { + return false + } + if (discountPrice != orderProductCommand.discountPrice) { + return false + } + if (getDeliveryFee(orderProductCommand.deliveryMethod) != orderProductCommand.deliveryFee) { + return false + } + return true + } +} diff --git a/src/main/kotlin/com/petqua/application/order/OrderService.kt b/src/main/kotlin/com/petqua/application/order/OrderService.kt index 60cb80a2..db6a8f27 100644 --- a/src/main/kotlin/com/petqua/application/order/OrderService.kt +++ b/src/main/kotlin/com/petqua/application/order/OrderService.kt @@ -1,29 +1,37 @@ package com.petqua.application.order +import com.petqua.application.order.dto.OrderProductCommand import com.petqua.application.order.dto.SaveOrderCommand import com.petqua.application.order.dto.SaveOrderResponse import com.petqua.application.payment.infra.PaymentGatewayClient -import com.petqua.common.domain.Money import com.petqua.common.domain.findByIdOrThrow import com.petqua.common.util.throwExceptionWhen +import com.petqua.domain.delivery.DeliveryMethod.PICK_UP import com.petqua.domain.order.Order import com.petqua.domain.order.OrderName import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPayment +import com.petqua.domain.order.OrderPaymentRepository import com.petqua.domain.order.OrderRepository import com.petqua.domain.order.OrderShippingAddress import com.petqua.domain.order.OrderStatus.ORDER_CREATED +import com.petqua.domain.order.ShippingAddress import com.petqua.domain.order.ShippingAddressRepository import com.petqua.domain.order.ShippingNumber +import com.petqua.domain.product.Product 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.store.StoreRepository import com.petqua.exception.order.OrderException -import com.petqua.exception.order.OrderExceptionType.ORDER_PRICE_NOT_MATCH +import com.petqua.exception.order.OrderExceptionType.EMPTY_SHIPPING_ADDRESS import com.petqua.exception.order.OrderExceptionType.PRODUCT_NOT_FOUND +import com.petqua.exception.order.OrderExceptionType.STORE_NOT_FOUND import com.petqua.exception.order.ShippingAddressException import com.petqua.exception.order.ShippingAddressExceptionType.NOT_FOUND_SHIPPING_ADDRESS import com.petqua.exception.product.ProductException -import com.petqua.exception.product.ProductExceptionType.INVALID_PRODUCT_OPTION +import com.petqua.exception.product.ProductExceptionType.NOT_FOUND_PRODUCT import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -31,107 +39,95 @@ import org.springframework.transaction.annotation.Transactional @Service class OrderService( private val orderRepository: OrderRepository, + private val orderPaymentRepository: OrderPaymentRepository, private val productRepository: ProductRepository, private val productOptionRepository: ProductOptionRepository, + private val productSnapshotRepository: ProductSnapshotRepository, private val shippingAddressRepository: ShippingAddressRepository, private val storeRepository: StoreRepository, private val paymentGatewayClient: PaymentGatewayClient, ) { fun save(command: SaveOrderCommand): SaveOrderResponse { - // TODO 상품 존재 검증 val productIds = command.orderProductCommands.map { it.productId } val productById = productRepository.findAllByIsDeletedFalseAndIdIn(productIds).associateBy { it.id } - val products = productById.map { it.value } - throwExceptionWhen(products.size != productIds.size) { OrderException(PRODUCT_NOT_FOUND) } + validateOrderProducts(command, productById) + val shippingAddress = findValidateShippingAddress(command.shippingAddressId, command.orderProductCommands) + val productSnapshots = findValidateProductSnapshots(productById) - // TODO 상품 유효성 검증 - 올바른 옵션 매칭인가? - val productOptions = productOptionRepository.findByProductIdIn(productIds) - - command.orderProductCommands.forEach { productOptionCommand -> - productOptions.find { it.productId == productOptionCommand.productId }?.let { - throwExceptionWhen(!it.isSame(productOptionCommand.toProductOption())) { - ProductException(INVALID_PRODUCT_OPTION) - } - } ?: throw ProductException(INVALID_PRODUCT_OPTION) - } - - // TODO 배송지 존재 검증 - val shippingAddress = shippingAddressRepository.findByIdOrThrow(command.shippingAddressId) { - ShippingAddressException(NOT_FOUND_SHIPPING_ADDRESS) - } + // TODO: TODO 재고 검증 + val orders = saveOrders(command, productSnapshots, shippingAddress) + orderPaymentRepository.saveAll(orders.map { OrderPayment.from(it) }) + return SaveOrderResponse( + orderId = orders.first().orderNumber.value, + orderName = orders.first().orderName.value, + ) + } - // TODO 총 가격 검증 - // 1. 상품 가격 - command.orderProductCommands.forEach { productCommand -> - val product = productById[productCommand.productId] - ?: throw OrderException(PRODUCT_NOT_FOUND) - val productOption = productOptions.find { it.productId == product.id } - ?: throw ProductException(INVALID_PRODUCT_OPTION) + private fun validateOrderProducts(command: SaveOrderCommand, productById: Map) { + val productOptions = productOptionRepository.findByProductIdIn(productById.keys.toList()) + val orderProductsValidator = OrderProductsValidator(productById, productOptions.toSet()) + orderProductsValidator.validate(command.totalAmount, command.orderProductCommands) + } - throwExceptionWhen( - productCommand.orderPrice != (product.discountPrice + productOption.additionalPrice) * productCommand.quantity.toBigDecimal() - || productCommand.deliveryFee != product.getDeliveryFee(productCommand.deliveryMethod) - ) { - OrderException( - ORDER_PRICE_NOT_MATCH - ) + private fun findValidateShippingAddress( + shippingAddressId: Long?, + orderProductCommands: List + ): ShippingAddress? { + if (shippingAddressId == null) { + throwExceptionWhen(orderProductCommands.any { it.deliveryMethod != PICK_UP }) { + OrderException(EMPTY_SHIPPING_ADDRESS) } + return null } - // 3. 총 배송비 검증 (스토어로 묶인 뒤 배송비 검증) - val groupBy = products.groupBy { product -> - Pair( - product.storeId, - command.orderProductCommands.find { it.productId == product.id }?.deliveryMethod - ?: throw OrderException(PRODUCT_NOT_FOUND) - ) + return shippingAddressRepository.findByIdOrThrow(shippingAddressId) { + ShippingAddressException(NOT_FOUND_SHIPPING_ADDRESS) } - val orderDeliveryFee = groupBy.map { (storeDeliveryMethod, products) -> - val deliveryMethod = storeDeliveryMethod.second - products.first().getDeliveryFee(deliveryMethod).value.toInt() // TODO int로 바꾼 이유? - }.sum() + } - // 4. 총 결제 금액 검증 - throwExceptionWhen(command.totalAmount != Money.from(orderDeliveryFee.toBigDecimal() + command.orderProductCommands.sumOf { it.orderPrice.value })) { - OrderException( - ORDER_PRICE_NOT_MATCH - ) + private fun findValidateProductSnapshots(productById: Map): Map { + val productIds = productById.keys.toList() + val products = productById.values.toList() + val productSnapshots = productSnapshotRepository.findLatestAllByProductIdIn(productIds).associateBy { it.productId } + products.forEach { product -> + productSnapshots[product.id]?.takeIf { it.isProductDetailsMatching(product) } + ?: throw ProductException(NOT_FOUND_PRODUCT) } + return productSnapshots + } - // TODO: TODO 재고 검증 - - val storesById = storeRepository.findByIdIn(products.map { it.storeId }).associateBy { it.id } + private fun saveOrders( + command: SaveOrderCommand, + productSnapshotsById: Map, + shippingAddress: ShippingAddress? + ): List { + val productSnapshots = productSnapshotsById.values.toList() + val storesById = storeRepository.findByIdIn(productSnapshots.map { it.storeId }).associateBy { it.id } val orderNumber = OrderNumber.generate() - val orderName = OrderName.from(products) - // TODO 주문 저장 로직 + val orderName = OrderName.from(productSnapshots) val orders = command.orderProductCommands.map { productCommand -> - val product = productById[productCommand.productId] + val productSnapshot = productSnapshotsById[productCommand.productId] ?: throw OrderException(PRODUCT_NOT_FOUND) - + val orderShippingAddress = shippingAddress?.let { OrderShippingAddress.from(it, command.shippingRequest) } Order( memberId = command.memberId, orderNumber = orderNumber, orderName = orderName, - orderShippingAddress = OrderShippingAddress.from(shippingAddress, command.shippingRequest), + orderShippingAddress = orderShippingAddress, orderProduct = productCommand.toOrderProduct( - shippingNumber = ShippingNumber.of(product.storeId, productCommand.deliveryMethod, orderNumber), - product = product, - storeName = storesById[product.storeId]?.name ?: throw OrderException(PRODUCT_NOT_FOUND), + shippingNumber = ShippingNumber.of( + productSnapshot.storeId, + productCommand.deliveryMethod, + orderNumber + ), + productSnapshot = productSnapshot, + storeName = storesById[productSnapshot.storeId]?.name ?: throw OrderException(STORE_NOT_FOUND), ), - isAbleToCancel = true, - status = ORDER_CREATED, totalAmount = command.totalAmount, ) } - orderRepository.saveAll(orders) - - return SaveOrderResponse( - orderId = orders.first().orderNumber.value, - orderName = orders.first().orderName.value, - successUrl = paymentGatewayClient.successUrl(), - failUrl = paymentGatewayClient.failUrl(), - ) + return orderRepository.saveAll(orders) } } diff --git a/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt b/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt index ef36fedc..ed3f76de 100644 --- a/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt +++ b/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt @@ -4,13 +4,14 @@ import com.petqua.common.domain.Money import com.petqua.domain.delivery.DeliveryMethod import com.petqua.domain.order.OrderProduct import com.petqua.domain.order.ShippingNumber -import com.petqua.domain.product.Product +import com.petqua.domain.product.ProductSnapshot import com.petqua.domain.product.option.ProductOption import com.petqua.domain.product.option.Sex +import io.swagger.v3.oas.annotations.media.Schema data class SaveOrderCommand( val memberId: Long, - val shippingAddressId: Long, + val shippingAddressId: Long?, val shippingRequest: String?, val orderProductCommands: List, val totalAmount: Money, @@ -39,7 +40,7 @@ data class OrderProductCommand( fun toOrderProduct( shippingNumber: ShippingNumber, - product: Product, + productSnapshot: ProductSnapshot, storeName: String, ): OrderProduct { return OrderProduct( @@ -51,9 +52,9 @@ data class OrderProductCommand( shippingNumber = shippingNumber, orderPrice = orderPrice, productId = productId, - productName = product.name, - thumbnailUrl = product.thumbnailUrl, - storeId = product.storeId, + productName = productSnapshot.name, + thumbnailUrl = productSnapshot.thumbnailUrl, + storeId = productSnapshot.storeId, storeName = storeName, deliveryMethod = deliveryMethod, sex = sex, @@ -62,8 +63,15 @@ data class OrderProductCommand( } data class SaveOrderResponse( + @Schema( + description = "주문 id", + example = "202402211607026029E90DB030" + ) val orderId: String, + + @Schema( + description = "주문 이름", + example = "네온 블루 구피 외 3건" + ) val orderName: String, - val successUrl: String, - val failUrl: String, ) diff --git a/src/main/kotlin/com/petqua/application/payment/PaymentService.kt b/src/main/kotlin/com/petqua/application/payment/PaymentService.kt index 3bc005f4..a2b3b201 100644 --- a/src/main/kotlin/com/petqua/application/payment/PaymentService.kt +++ b/src/main/kotlin/com/petqua/application/payment/PaymentService.kt @@ -1,14 +1,19 @@ package com.petqua.application.payment +import com.petqua.domain.order.OrderGroup import com.petqua.domain.order.OrderNumber -import com.petqua.domain.order.OrderPayment import com.petqua.domain.order.OrderPaymentRepository import com.petqua.domain.order.OrderRepository import com.petqua.domain.order.findByOrderNumberOrThrow +import com.petqua.domain.order.findLatestByOrderIdOrThrow +import com.petqua.domain.order.saveOrThrowOnIntegrityViolation import com.petqua.domain.payment.tosspayment.TossPayment import com.petqua.domain.payment.tosspayment.TossPaymentRepository import com.petqua.exception.order.OrderException import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND +import com.petqua.exception.order.OrderPaymentException +import com.petqua.exception.order.OrderPaymentExceptionType.FAIL_SAVE +import com.petqua.exception.order.OrderPaymentExceptionType.ORDER_PAYMENT_NOT_FOUND import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -22,33 +27,45 @@ class PaymentService( @Transactional(readOnly = true) fun validateAmount(command: SucceedPaymentCommand) { - val order = orderRepository.findByOrderNumberOrThrow(command.orderNumber) { + val orders = orderRepository.findByOrderNumberOrThrow(command.orderNumber) { OrderException(ORDER_NOT_FOUND) } - order.validateOwner(command.memberId) - order.validateAmount(command.amount) + val orderGroup = OrderGroup(orders) + orderGroup.validateOwner(command.memberId) + orderGroup.validateAmount(command.amount) } - fun processPayment(tossPayment: TossPayment): OrderPayment { - val order = orderRepository.findByOrderNumberOrThrow(tossPayment.orderNumber) { + fun processPayment(tossPayment: TossPayment) { + val orders = orderRepository.findByOrderNumberOrThrow(tossPayment.orderNumber) { OrderException(ORDER_NOT_FOUND) } - order.pay() - + val orderGroup = OrderGroup(orders) val payment = paymentRepository.save(tossPayment) - return orderPaymentRepository.save( - OrderPayment( - orderId = order.id, - tossPaymentId = payment.id - ) - ) + orderGroup.ordersWithSameOrderNumber.map { + val orderPayment = + orderPaymentRepository.findLatestByOrderIdOrThrow(it.id) { + OrderPaymentException(ORDER_PAYMENT_NOT_FOUND) + } + orderPaymentRepository.saveOrThrowOnIntegrityViolation(orderPayment.pay(payment.id)) { + OrderPaymentException(FAIL_SAVE) + } + } } fun cancelOrder(memberId: Long, orderNumber: OrderNumber) { - val order = orderRepository.findByOrderNumberOrThrow(orderNumber) { + val orders = orderRepository.findByOrderNumberOrThrow(orderNumber) { OrderException(ORDER_NOT_FOUND) } - order.validateOwner(memberId) - order.cancel() + val orderGroup = OrderGroup(orders) + orderGroup.validateOwner(memberId) + + orderGroup.ordersWithSameOrderNumber.map { + val orderPayment = orderPaymentRepository.findLatestByOrderIdOrThrow(it.id) { + OrderPaymentException(ORDER_PAYMENT_NOT_FOUND) + } + orderPaymentRepository.saveOrThrowOnIntegrityViolation(orderPayment.cancel()) { + OrderPaymentException(FAIL_SAVE) + } + } } } diff --git a/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt b/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt index 91d40511..de3c0937 100644 --- a/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt +++ b/src/main/kotlin/com/petqua/application/payment/infra/PaymentGatewayClient.kt @@ -6,8 +6,4 @@ import com.petqua.application.payment.PaymentResponseFromPG interface PaymentGatewayClient { fun confirmPayment(paymentConfirmRequestToPG: PaymentConfirmRequestToPG): PaymentResponseFromPG - - fun successUrl(): String - - fun failUrl(): String } diff --git a/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt b/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt index 0c77a3af..bd23a4d3 100644 --- a/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt +++ b/src/main/kotlin/com/petqua/application/payment/infra/TossPaymentClient.kt @@ -36,12 +36,4 @@ class TossPaymentClient( throw PaymentException(PaymentExceptionType.from(errorResponse.code)) } } - - override fun successUrl(): String { - return paymentProperties.successUrl - } - - override fun failUrl(): String { - return paymentProperties.failUrl - } } diff --git a/src/main/kotlin/com/petqua/application/product/dto/ProductDtos.kt b/src/main/kotlin/com/petqua/application/product/dto/ProductDtos.kt index 2d85d275..223eb247 100644 --- a/src/main/kotlin/com/petqua/application/product/dto/ProductDtos.kt +++ b/src/main/kotlin/com/petqua/application/product/dto/ProductDtos.kt @@ -48,6 +48,12 @@ data class ProductDetailResponse( ) val price: Money, + @Schema( + description = "상품 판매점 id", + example = "1" + ) + val storeId: Long, + @Schema( description = "상품 판매점", example = "S아쿠아" @@ -187,6 +193,7 @@ data class ProductDetailResponse( family = productWithInfoResponse.family, species = productWithInfoResponse.species, price = productWithInfoResponse.price, + storeId = productWithInfoResponse.storeId, storeName = productWithInfoResponse.storeName, discountRate = productWithInfoResponse.discountRate, discountPrice = productWithInfoResponse.discountPrice, diff --git a/src/main/kotlin/com/petqua/common/config/DataInitializer.kt b/src/main/kotlin/com/petqua/common/config/DataInitializer.kt index cee9e484..2553c17d 100644 --- a/src/main/kotlin/com/petqua/common/config/DataInitializer.kt +++ b/src/main/kotlin/com/petqua/common/config/DataInitializer.kt @@ -26,6 +26,8 @@ import com.petqua.domain.policy.bannedword.BannedWord import com.petqua.domain.policy.bannedword.BannedWordRepository import com.petqua.domain.product.Product import com.petqua.domain.product.ProductRepository +import com.petqua.domain.product.ProductSnapshot +import com.petqua.domain.product.ProductSnapshotRepository import com.petqua.domain.product.WishCount import com.petqua.domain.product.WishProduct import com.petqua.domain.product.WishProductRepository @@ -86,6 +88,7 @@ class DataInitializer( private val productInfoRepository: ProductInfoRepository, private val productImageRepository: ProductImageRepository, private val productOptionRepository: ProductOptionRepository, + private val productSnapshotRepository: ProductSnapshotRepository, private val wishProductRepository: WishProductRepository, private val productKeywordRepository: ProductKeywordRepository, private val productDescriptionRepository: ProductDescriptionRepository, @@ -307,6 +310,7 @@ class DataInitializer( ) } productRepository.saveAll(products) + productSnapshotRepository.saveAll(products.map { ProductSnapshot.from(it) }) val productOptions = products.map { val sex = when { diff --git a/src/main/kotlin/com/petqua/common/domain/Money.kt b/src/main/kotlin/com/petqua/common/domain/Money.kt index 07d0bee1..6eee8f43 100644 --- a/src/main/kotlin/com/petqua/common/domain/Money.kt +++ b/src/main/kotlin/com/petqua/common/domain/Money.kt @@ -21,7 +21,7 @@ private class MoneyDeserializer : JsonDeserializer() { private class MoneySerializer : JsonSerializer() { override fun serialize(money: Money, gen: JsonGenerator, serializers: SerializerProvider) { - gen.writeNumber(money.value.intValueExact()) // TODO INT? BigDecimal? + gen.writeNumber(money.value.intValueExact()) } } diff --git a/src/main/kotlin/com/petqua/common/util/EntityManagerUtils.kt b/src/main/kotlin/com/petqua/common/util/EntityManagerUtils.kt index 84bf05c0..d7e2a093 100644 --- a/src/main/kotlin/com/petqua/common/util/EntityManagerUtils.kt +++ b/src/main/kotlin/com/petqua/common/util/EntityManagerUtils.kt @@ -24,6 +24,22 @@ inline fun EntityManager.createSingleQueryOrThrow( } } +inline fun EntityManager.createFirstQueryOrThrow( + query: SelectQuery<*>, + context: JpqlRenderContext, + renderer: JpqlRenderer, + exceptionSupplier: () -> RuntimeException = { NoResultException("Query did not return any result") }, +): T { + val rendered = renderer.render(query, context) + val results = this.createQuery(rendered.query, T::class.java) + .apply { rendered.params.forEach { (name, value) -> setParameter(name, value) } } + .resultList + return when { + results.isEmpty() -> throw exceptionSupplier() + else -> results[0] + } +} + inline fun EntityManager.createQuery( query: SelectQuery<*>, context: JpqlRenderContext, diff --git a/src/main/kotlin/com/petqua/domain/cart/CartProductCustomRepositoryImpl.kt b/src/main/kotlin/com/petqua/domain/cart/CartProductCustomRepositoryImpl.kt index f21f3999..c60848ee 100644 --- a/src/main/kotlin/com/petqua/domain/cart/CartProductCustomRepositoryImpl.kt +++ b/src/main/kotlin/com/petqua/domain/cart/CartProductCustomRepositoryImpl.kt @@ -22,7 +22,7 @@ class CartProductCustomRepositoryImpl( selectNew( entity(CartProduct::class), entity(Product::class), - path(Store::name) + entity(Store::class) ).from( entity(CartProduct::class), leftJoin(Product::class).on(path(CartProduct::productId).eq(path(Product::id))), diff --git a/src/main/kotlin/com/petqua/domain/order/DeliveryGroupKey.kt b/src/main/kotlin/com/petqua/domain/order/DeliveryGroupKey.kt new file mode 100644 index 00000000..00a29cd9 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/DeliveryGroupKey.kt @@ -0,0 +1,8 @@ +package com.petqua.domain.order + +import com.petqua.domain.delivery.DeliveryMethod + +data class DeliveryGroupKey( + val storeId: Long, + val deliveryMethod: DeliveryMethod, +) diff --git a/src/main/kotlin/com/petqua/domain/order/Order.kt b/src/main/kotlin/com/petqua/domain/order/Order.kt index e866bda8..b59841bf 100644 --- a/src/main/kotlin/com/petqua/domain/order/Order.kt +++ b/src/main/kotlin/com/petqua/domain/order/Order.kt @@ -5,19 +5,15 @@ import com.petqua.common.domain.Money import com.petqua.common.util.throwExceptionWhen import com.petqua.exception.order.OrderException import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER -import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY import com.petqua.exception.order.OrderExceptionType.PAYMENT_PRICE_NOT_MATCH import jakarta.persistence.AttributeOverride import jakarta.persistence.Column import jakarta.persistence.Embedded import jakarta.persistence.Entity -import jakarta.persistence.EnumType.STRING -import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType.IDENTITY import jakarta.persistence.Id import jakarta.persistence.Table -import java.math.BigDecimal @Table(name = "orders") @Entity @@ -37,18 +33,11 @@ class Order( val orderName: OrderName, @Embedded - val orderShippingAddress: OrderShippingAddress, + val orderShippingAddress: OrderShippingAddress?, @Embedded val orderProduct: OrderProduct, - @Column(nullable = false) - val isAbleToCancel: Boolean, - - @Enumerated(STRING) - @Column(nullable = false) - var status: OrderStatus, - @Embedded @AttributeOverride(name = "value", column = Column(name = "total_amount")) val totalAmount: Money, @@ -65,19 +54,4 @@ class Order( throw OrderException(FORBIDDEN_ORDER) } } - - fun cancel() { - // TODO isAbleToCancel 사용 - // throwExceptionWhen(!isAbleToCancel) { - // OrderException(ORDER_NOT_FOUND) - // } - status = OrderStatus.CANCELED - } - - fun pay() { - throwExceptionWhen(!status.isAbleToPay()) { - throw OrderException(ORDER_CAN_NOT_PAY) - } - status = OrderStatus.PAYMENT_CONFIRMED - } } diff --git a/src/main/kotlin/com/petqua/domain/order/OrderGroup.kt b/src/main/kotlin/com/petqua/domain/order/OrderGroup.kt new file mode 100644 index 00000000..55264519 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderGroup.kt @@ -0,0 +1,20 @@ +package com.petqua.domain.order + +import com.petqua.common.domain.Money + +class OrderGroup( + val ordersWithSameOrderNumber: List, +) { + + fun validateOwner(memberId: Long) { + ordersWithSameOrderNumber[FIRST].validateOwner(memberId) + } + + fun validateAmount(amount: Money) { + ordersWithSameOrderNumber[FIRST].validateAmount(amount) + } + + companion object { + private const val FIRST = 0; + } +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderName.kt b/src/main/kotlin/com/petqua/domain/order/OrderName.kt index 679454ba..d546f47f 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderName.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderName.kt @@ -1,6 +1,6 @@ package com.petqua.domain.order -import com.petqua.domain.product.Product +import com.petqua.domain.product.ProductSnapshot import jakarta.persistence.Column import jakarta.persistence.Embeddable @@ -11,8 +11,8 @@ data class OrderName( ) { companion object { - fun from(products: List): OrderName { - return OrderName("${products.first().name} 외 ${products.size - 1}건") + fun from(productSnapshots: List): OrderName { + return OrderName("${productSnapshots.first().name} 외 ${productSnapshots.size - 1}건") } } } diff --git a/src/main/kotlin/com/petqua/domain/order/OrderPayment.kt b/src/main/kotlin/com/petqua/domain/order/OrderPayment.kt index fad0a837..982083a0 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderPayment.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderPayment.kt @@ -1,6 +1,14 @@ package com.petqua.domain.order +import com.petqua.common.util.throwExceptionWhen +import com.petqua.domain.order.OrderStatus.ORDER_CREATED +import com.petqua.domain.order.OrderStatus.PAYMENT_CONFIRMED +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType +import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id @@ -12,5 +20,46 @@ class OrderPayment( val orderId: Long, - val tossPaymentId: Long, -) + val tossPaymentId: Long = 0L, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val status: OrderStatus = ORDER_CREATED, + + @Column(unique = true) + val prevId: Long? = null, +) { + + companion object { + fun from(order: Order): OrderPayment { + return OrderPayment( + orderId = order.id, + ) + } + } + + fun cancel(): OrderPayment { + // TODO isAbleToCancel 사용 + // throwExceptionWhen(!isAbleToCancel) { + // OrderException(ORDER_NOT_FOUND) + // } + return OrderPayment( + orderId = orderId, + tossPaymentId = tossPaymentId, + status = OrderStatus.CANCELED, + prevId = id, + ) + } + + fun pay(tossPaymentId: Long): OrderPayment { + throwExceptionWhen(!status.isAbleToPay()) { + throw OrderException(OrderExceptionType.ORDER_CAN_NOT_PAY) + } + return OrderPayment( + orderId = orderId, + tossPaymentId = tossPaymentId, + status = PAYMENT_CONFIRMED, + prevId = id, + ) + } +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderPaymentCustomRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderPaymentCustomRepository.kt new file mode 100644 index 00000000..1fc231cc --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderPaymentCustomRepository.kt @@ -0,0 +1,6 @@ +package com.petqua.domain.order + +interface OrderPaymentCustomRepository { + + fun findLatestByOrderIdOrThrow(orderId: Long, exceptionSupplier: () -> RuntimeException): OrderPayment +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderPaymentCustomRepositoryImpl.kt b/src/main/kotlin/com/petqua/domain/order/OrderPaymentCustomRepositoryImpl.kt new file mode 100644 index 00000000..d9fbb999 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderPaymentCustomRepositoryImpl.kt @@ -0,0 +1,40 @@ +package com.petqua.domain.order + +import com.linecorp.kotlinjdsl.dsl.jpql.jpql +import com.linecorp.kotlinjdsl.render.jpql.JpqlRenderContext +import com.linecorp.kotlinjdsl.render.jpql.JpqlRenderer +import com.petqua.common.util.createFirstQueryOrThrow +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Repository + +@Repository +class OrderPaymentCustomRepositoryImpl( + private val entityManager: EntityManager, + private val jpqlRenderContext: JpqlRenderContext, + private val jpqlRenderer: JpqlRenderer, +) : OrderPaymentCustomRepository { + + override fun findLatestByOrderIdOrThrow( + orderId: Long, + exceptionSupplier: () -> RuntimeException, + ): OrderPayment { + val query = jpql { + select( + entity(OrderPayment::class), + ).from( + entity(OrderPayment::class), + ).whereAnd( + path(OrderPayment::orderId).eq(orderId) + ).orderBy( + path(OrderPayment::id).desc() + ) + } + + return entityManager.createFirstQueryOrThrow( + query, + jpqlRenderContext, + jpqlRenderer, + exceptionSupplier + ) + } +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt index 14ba41bb..dd260cb2 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt @@ -1,5 +1,34 @@ package com.petqua.domain.order +import org.springframework.dao.DataIntegrityViolationException import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query -interface OrderPaymentRepository : JpaRepository +fun OrderPaymentRepository.findLatestByOrderIdOrThrow( + orderId: Long, + exceptionSupplier: () -> Exception = { IllegalArgumentException("${OrderPayment::class.java.name} entity 를 찾을 수 없습니다.") }, +): OrderPayment { + return findTopByOrderIdOrderByIdDesc(orderId) ?: throw exceptionSupplier() +} + +fun OrderPaymentRepository.saveOrThrowOnIntegrityViolation( + orderPayment: OrderPayment, + exceptionSupplier: () -> Exception = { IllegalArgumentException("${OrderPayment::class.java.name} entity 를 저장할 수 없습니다.") }, +): OrderPayment { + try { + return save(orderPayment) + } catch (e: DataIntegrityViolationException) { + throw exceptionSupplier() + } +} + +interface OrderPaymentRepository : JpaRepository { + + fun findTopByOrderIdOrderByIdDesc(orderId: Long): OrderPayment? + + @Query("SELECT op FROM OrderPayment op WHERE op.orderId IN :orderIds ORDER BY op.id DESC") + fun findLatestAllByOrderIds(orderIds: List): List + + @Query("SELECT op.id FROM OrderPayment op WHERE op.orderId = ?1 ORDER BY op.id DESC") + fun getPrevIdByOrderId(orderId: Long): Long? +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt index 177722eb..a2384e72 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt @@ -4,12 +4,13 @@ import org.springframework.data.jpa.repository.JpaRepository fun OrderRepository.findByOrderNumberOrThrow( orderNumber: OrderNumber, - exceptionSupplier: () -> Exception = { IllegalArgumentException("${Order::class.java.name} entity 를 찾을 수 없습니다.") } -): Order { - return findByOrderNumber(orderNumber) ?: throw exceptionSupplier() + exceptionSupplier: () -> Exception = { IllegalArgumentException("${Order::class.java.name} entity 를 찾을 수 없습니다.") }, +): List { + val orders = findByOrderNumber(orderNumber) + return orders.ifEmpty { throw exceptionSupplier() } } interface OrderRepository : JpaRepository { - fun findByOrderNumber(orderNumber: OrderNumber): Order? + fun findByOrderNumber(orderNumber: OrderNumber): List } diff --git a/src/main/kotlin/com/petqua/domain/product/ProductCustomRepositoryImpl.kt b/src/main/kotlin/com/petqua/domain/product/ProductCustomRepositoryImpl.kt index ff4f0934..3c01fa70 100644 --- a/src/main/kotlin/com/petqua/domain/product/ProductCustomRepositoryImpl.kt +++ b/src/main/kotlin/com/petqua/domain/product/ProductCustomRepositoryImpl.kt @@ -41,7 +41,7 @@ class ProductCustomRepositoryImpl( val query = jpql(ProductDynamicJpqlGenerator) { selectNew( entity(Product::class), - path(Store::name), + entity(Store::class), new( ProductDescriptionResponse::class, coalesce(path(ProductDescription::title)(ProductDescriptionTitle::value), EMPTY_VALUE), diff --git a/src/main/kotlin/com/petqua/domain/product/ProductSnapshot.kt b/src/main/kotlin/com/petqua/domain/product/ProductSnapshot.kt new file mode 100644 index 00000000..04480fe5 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/product/ProductSnapshot.kt @@ -0,0 +1,90 @@ +package com.petqua.domain.product + +import com.petqua.common.domain.BaseEntity +import com.petqua.common.domain.Money +import jakarta.persistence.AttributeOverride +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class ProductSnapshot( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(nullable = false) + val productId: Long = 0L, + + @Column(nullable = false) + val name: String, + + @Column(nullable = false) + val categoryId: Long, + + @Embedded + @AttributeOverride(name = "value", column = Column(name = "price", nullable = false)) + val price: Money, + + @Column(nullable = false) + val storeId: Long, + + @Column(nullable = false) + val discountRate: Int = 0, + + @Embedded + @AttributeOverride(name = "value", column = Column(name = "discount_price", nullable = false)) + val discountPrice: Money = price, + + @Column(nullable = false) + val thumbnailUrl: String, + + @Embedded + @AttributeOverride(name = "value", column = Column(name = "safe_delivery_fee")) + val safeDeliveryFee: Money?, + + @Embedded + @AttributeOverride(name = "value", column = Column(name = "common_delivery_fee")) + val commonDeliveryFee: Money?, + + @Embedded + @AttributeOverride(name = "value", column = Column(name = "pick_up_delivery_fee")) + val pickUpDeliveryFee: Money?, + + val productDescriptionId: Long?, + + @Column(nullable = false) + val productInfoId: Long, +) : BaseEntity() { + + companion object { + fun from(product: Product): ProductSnapshot { + return ProductSnapshot( + productId = product.id, + name = product.name, + categoryId = product.categoryId, + price = product.price, + storeId = product.storeId, + discountRate = product.discountRate, + discountPrice = product.discountPrice, + thumbnailUrl = product.thumbnailUrl, + safeDeliveryFee = product.safeDeliveryFee, + commonDeliveryFee = product.commonDeliveryFee, + pickUpDeliveryFee = product.pickUpDeliveryFee, + productDescriptionId = product.productDescriptionId, + productInfoId = product.productInfoId, + ) + } + } + + fun isProductDetailsMatching(product: Product): Boolean { + return product.id == productId && product.name == name && product.categoryId == categoryId && product.price == price && + product.storeId == storeId && product.discountRate == discountRate && product.discountPrice == discountPrice && + product.thumbnailUrl == thumbnailUrl && product.safeDeliveryFee == safeDeliveryFee && product.commonDeliveryFee == commonDeliveryFee && + product.pickUpDeliveryFee == pickUpDeliveryFee && product.productDescriptionId == productDescriptionId && product.productInfoId == productInfoId + } +} diff --git a/src/main/kotlin/com/petqua/domain/product/ProductSnapshotRepository.kt b/src/main/kotlin/com/petqua/domain/product/ProductSnapshotRepository.kt new file mode 100644 index 00000000..a2762fd3 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/product/ProductSnapshotRepository.kt @@ -0,0 +1,12 @@ +package com.petqua.domain.product + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface ProductSnapshotRepository : JpaRepository { + + fun findByProductIdOrderByIdDesc(productId: Long): ProductSnapshot? + + @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 findLatestAllByProductIdIn(productIds: List): List +} diff --git a/src/main/kotlin/com/petqua/domain/product/dto/ProductDtos.kt b/src/main/kotlin/com/petqua/domain/product/dto/ProductDtos.kt index e182bc4d..54dfb7da 100644 --- a/src/main/kotlin/com/petqua/domain/product/dto/ProductDtos.kt +++ b/src/main/kotlin/com/petqua/domain/product/dto/ProductDtos.kt @@ -8,6 +8,7 @@ import com.petqua.domain.product.ProductSourceType import com.petqua.domain.product.Sorter import com.petqua.domain.product.category.Category import com.petqua.domain.product.detail.info.ProductInfo +import com.petqua.domain.store.Store import com.petqua.exception.product.ProductException import com.petqua.exception.product.ProductExceptionType import io.swagger.v3.oas.annotations.media.Schema @@ -64,6 +65,7 @@ data class ProductWithInfoResponse( val family: String, val species: String, val price: Money, + val storeId: Long, val storeName: String, val discountRate: Int, val discountPrice: Money, @@ -84,7 +86,7 @@ data class ProductWithInfoResponse( ) { constructor( product: Product, - storeName: String, + store: Store, productDescription: ProductDescriptionResponse, productInfo: ProductInfo, category: Category, @@ -94,7 +96,8 @@ data class ProductWithInfoResponse( family = category.family.name, species = category.species.name, price = product.price, - storeName = storeName, + storeId = store.id, + storeName = store.name, discountRate = product.discountRate, discountPrice = product.discountPrice, wishCount = product.wishCount.value, diff --git a/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt b/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt index 2ad58b67..771cf352 100644 --- a/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt +++ b/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt @@ -14,14 +14,16 @@ enum class OrderExceptionType( PRODUCT_NOT_FOUND(BAD_REQUEST, "O01", "주문한 상품이 존재하지 않습니다."), - ORDER_PRICE_NOT_MATCH(BAD_REQUEST, "O10", "주문한 상품의 가격이 일치하지 않습니다."), + ORDER_TOTAL_PRICE_NOT_MATCH(BAD_REQUEST, "O10", "주문한 상품의 총 가격이 일치하지 않습니다."), ORDER_NOT_FOUND(NOT_FOUND, "O11", "존재하지 않는 주문입니다."), PAYMENT_PRICE_NOT_MATCH(BAD_REQUEST, "O12", "주문한 상품 가격과 결제 금액이 일치하지 않습니다."), + EMPTY_SHIPPING_ADDRESS(BAD_REQUEST, "O13", "배송지가 입력되지 않았습니다."), + STORE_NOT_FOUND(BAD_REQUEST, "O14", "주문한 상품의 상점이 존재하지 않았습니다."), + PRODUCT_INFO_NOT_MATCH(BAD_REQUEST, "O15", "주문한 상품이 등록된 상품의 정보와 일치하지 않습니다."), INVALID_PAYMENT_TYPE(BAD_REQUEST, "O20", "유효하지 않은 결제 방식입니다."), FORBIDDEN_ORDER(FORBIDDEN, "O30", "해당 주문에 대한 권한이 없습니다."), - ORDER_CAN_NOT_CANCEL(BAD_REQUEST, "O31", "취소할 수 없는 주문입니다."), ORDER_CAN_NOT_PAY(BAD_REQUEST, "O32", "결제할 수 없는 주문입니다."), ; diff --git a/src/main/kotlin/com/petqua/exception/order/OrderPaymentException.kt b/src/main/kotlin/com/petqua/exception/order/OrderPaymentException.kt new file mode 100644 index 00000000..255a9e26 --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/order/OrderPaymentException.kt @@ -0,0 +1,13 @@ +package com.petqua.exception.order + +import com.petqua.common.exception.BaseException +import com.petqua.common.exception.BaseExceptionType + +class OrderPaymentException( + private val exceptionType: OrderPaymentExceptionType, +) : BaseException() { + + override fun exceptionType(): BaseExceptionType { + return exceptionType + } +} diff --git a/src/main/kotlin/com/petqua/exception/order/OrderPaymentExceptionType.kt b/src/main/kotlin/com/petqua/exception/order/OrderPaymentExceptionType.kt new file mode 100644 index 00000000..b4ecf68d --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/order/OrderPaymentExceptionType.kt @@ -0,0 +1,28 @@ +package com.petqua.exception.order + +import com.petqua.common.exception.BaseExceptionType +import org.springframework.http.HttpStatus + +enum class OrderPaymentExceptionType ( + private val httpStatus: HttpStatus, + private val code: String, + private val errorMessage: String, +) : BaseExceptionType { + + ORDER_PAYMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "OP01", "주문 결제 내역이 존재하지 않습니다."), + + FAIL_SAVE(HttpStatus.INTERNAL_SERVER_ERROR, "OP02", "주문 결제 내역 저장에 실패하였습니다."), + ; + + override fun httpStatus(): HttpStatus { + return httpStatus + } + + override fun code(): String { + return code + } + + override fun errorMessage(): String { + return errorMessage + } +} diff --git a/src/main/kotlin/com/petqua/exception/product/ProductSnapshotException.kt b/src/main/kotlin/com/petqua/exception/product/ProductSnapshotException.kt new file mode 100644 index 00000000..abd67b4f --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/product/ProductSnapshotException.kt @@ -0,0 +1,13 @@ +package com.petqua.exception.product + +import com.petqua.common.exception.BaseException +import com.petqua.common.exception.BaseExceptionType + +class ProductSnapshotException( + private val exceptionType: ProductSnapshotExceptionType, +) : BaseException() { + + override fun exceptionType(): BaseExceptionType { + return exceptionType + } +} diff --git a/src/main/kotlin/com/petqua/exception/product/ProductSnapshotExceptionType.kt b/src/main/kotlin/com/petqua/exception/product/ProductSnapshotExceptionType.kt new file mode 100644 index 00000000..e1ea90ad --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/product/ProductSnapshotExceptionType.kt @@ -0,0 +1,26 @@ +package com.petqua.exception.product + +import com.petqua.common.exception.BaseExceptionType +import org.springframework.http.HttpStatus + +enum class ProductSnapshotExceptionType( + private val httpStatus: HttpStatus, + private val code: String, + private val errorMessage: String, +) : BaseExceptionType { + + NOT_FOUND_PRODUCT_SNAPSHOT(HttpStatus.NOT_FOUND, "PS01", "존재하지 않는 상품 스냅샷입니다."), + ; + + override fun httpStatus(): HttpStatus { + return httpStatus + } + + override fun code(): String { + return code + } + + override fun errorMessage(): String { + return errorMessage + } +} diff --git a/src/main/kotlin/com/petqua/presentation/order/dto/OrderDtos.kt b/src/main/kotlin/com/petqua/presentation/order/dto/OrderDtos.kt index 4b28193d..ffef6bca 100644 --- a/src/main/kotlin/com/petqua/presentation/order/dto/OrderDtos.kt +++ b/src/main/kotlin/com/petqua/presentation/order/dto/OrderDtos.kt @@ -5,12 +5,31 @@ import com.petqua.application.order.dto.SaveOrderCommand import com.petqua.common.domain.Money import com.petqua.domain.delivery.DeliveryMethod import com.petqua.domain.product.option.Sex -import java.math.BigDecimal +import io.swagger.v3.oas.annotations.media.Schema data class SaveOrderRequest( - val shippingAddressId: Long, + @Schema( + description = "운송지 id", + example = "1" + ) + val shippingAddressId: Long?, + + @Schema( + description = "운송지 요청 사항", + example = "경비실에 맡겨주세요." + ) val shippingRequest: String?, + + @Schema( + description = "주문 상품 목록", + example = "[{\"productId\": 1, \"storeId\": 1, \"quantity\": 2, \"originalPrice\": 40000, \"discountRate\": 50, \"discountPrice\": 20000, \"orderPrice\": 22000, \"sex\": \"FEMALE\", \"additionalPrice\": 2000, \"deliveryFee\": 5000, \"deliveryMethod\": \"SAFETY\"}]" + ) val orderProductRequests: List, + + @Schema( + description = "주문 총금액", + example = "49000" + ) val totalAmount: Money, ) { @@ -26,16 +45,72 @@ data class SaveOrderRequest( } data class OrderProductRequest( + @Schema( + description = "상품 id", + example = "1" + ) val productId: Long, + + @Schema( + description = "상점 id", + example = "1" + ) val storeId: Long, + + @Schema( + description = "상품 수량", + example = "2" + ) val quantity: Int, + + @Schema( + description = "상품 기존 금액", + example = "40000" + ) val originalPrice: Money, + + @Schema( + description = "할인율", + example = "50" + ) val discountRate: Int, + + @Schema( + description = "할인 가격(판매 가격)", + example = "20000" + ) val discountPrice: Money, + + @Schema( + description = "주문한 상품 가격(할인 가격 + 옵션 추가 금액)", + example = "22000" + ) val orderPrice: Money, + + @Schema( + description = "성별", + defaultValue = "FEMALE", + allowableValues = ["FEMALE", "MALE", "HERMAPHRODITE"] + ) val sex: String, + + @Schema( + description = "옵션 추가 금액", + example = "2000" + ) val additionalPrice: Money, + + @Schema( + description = "운송비", + example = "5000", + ) val deliveryFee: Money, + + @Schema( + description = "운송 방법", + defaultValue = "SAFETY", + allowableValues = ["SAFETY", "COMMON", "PICK_UP"] + ) val deliveryMethod: String, ) { diff --git a/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt b/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt index 558b1b2e..20156eb5 100644 --- a/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt @@ -3,21 +3,26 @@ package com.petqua.application.order import com.petqua.domain.delivery.DeliveryMethod.COMMON import com.petqua.domain.delivery.DeliveryMethod.SAFETY import com.petqua.domain.member.MemberRepository +import com.petqua.domain.order.OrderPaymentRepository import com.petqua.domain.order.OrderRepository import com.petqua.domain.order.OrderStatus.ORDER_CREATED import com.petqua.domain.order.ShippingAddressRepository 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 import com.petqua.domain.product.option.Sex.FEMALE import com.petqua.domain.store.StoreRepository import com.petqua.exception.order.OrderException -import com.petqua.exception.order.OrderExceptionType.ORDER_PRICE_NOT_MATCH +import com.petqua.exception.order.OrderExceptionType.ORDER_TOTAL_PRICE_NOT_MATCH +import com.petqua.exception.order.OrderExceptionType.PRODUCT_INFO_NOT_MATCH import com.petqua.exception.order.OrderExceptionType.PRODUCT_NOT_FOUND import com.petqua.exception.order.ShippingAddressException import com.petqua.exception.order.ShippingAddressExceptionType.NOT_FOUND_SHIPPING_ADDRESS import com.petqua.exception.product.ProductException import com.petqua.exception.product.ProductExceptionType.INVALID_PRODUCT_OPTION +import com.petqua.exception.product.ProductExceptionType.NOT_FOUND_PRODUCT import com.petqua.test.DataCleaner import com.petqua.test.fixture.member import com.petqua.test.fixture.orderProductCommand @@ -38,10 +43,12 @@ import java.math.BigDecimal class OrderServiceTest( private val orderService: OrderService, private val orderRepository: OrderRepository, + private val orderPaymentRepository: OrderPaymentRepository, private val productRepository: ProductRepository, private val storeRepository: StoreRepository, private val memberRepository: MemberRepository, private val productOptionRepository: ProductOptionRepository, + private val productSnapshotRepository: ProductSnapshotRepository, private val shippingAddressRepository: ShippingAddressRepository, private val dataCleaner: DataCleaner, ) : BehaviorSpec({ @@ -52,6 +59,8 @@ class OrderServiceTest( val memberId = memberRepository.save(member()).id val productA = productRepository.save(product()) val productB = productRepository.save(product()) + productSnapshotRepository.save(ProductSnapshot.from(productA)) + productSnapshotRepository.save(ProductSnapshot.from(productB)) val orderProductCommands = listOf( orderProductCommand( productId = productA.id, @@ -79,6 +88,8 @@ class OrderServiceTest( val memberId = memberRepository.save(member()).id val productA = productRepository.save(product()) val productB = productRepository.save(product()) + productSnapshotRepository.save(ProductSnapshot.from(productA)) + productSnapshotRepository.save(ProductSnapshot.from(productB)) val productOptionA = productOptionRepository.save( productOption( productId = productA.id, @@ -118,7 +129,8 @@ class OrderServiceTest( When("존재 하지 않는 배송 정보를 입력 하면") { val memberId = memberRepository.save(member()).id - val productA = productRepository.save(product()) + val productA = productRepository.save(product(commonDeliveryFee = 3000.toBigDecimal())) + productSnapshotRepository.save(ProductSnapshot.from(productA)) val productOptionA = productOptionRepository.save( productOption( productId = productA.id, @@ -131,6 +143,7 @@ class OrderServiceTest( productId = productA.id, sex = productOptionA.sex, additionalPrice = productOptionA.additionalPrice.value, + orderPrice = 2.toBigDecimal() ), ) @@ -138,6 +151,7 @@ class OrderServiceTest( memberId = memberId, orderProductCommands = orderProductCommands, shippingAddressId = Long.MIN_VALUE, + totalAmount = 3002.toBigDecimal() ) Then("예외가 발생 한다") { @@ -165,6 +179,9 @@ class OrderServiceTest( safeDeliveryFee = 5000.toBigDecimal(), ) ) + productSnapshotRepository.save(ProductSnapshot.from(productA)) + productSnapshotRepository.save(ProductSnapshot.from(productB)) + val productOptionA = productOptionRepository.save( productOption( productId = productA.id, @@ -193,6 +210,7 @@ class OrderServiceTest( sex = productOptionA.sex, orderPrice = BigDecimal.TEN, additionalPrice = productOptionA.additionalPrice.value, + deliveryFee = productA.commonDeliveryFee!!.value, deliveryMethod = COMMON, ), orderProductCommand( @@ -203,6 +221,7 @@ class OrderServiceTest( sex = productOptionB.sex, orderPrice = 9.toBigDecimal(), additionalPrice = productOptionB.additionalPrice.value, + deliveryFee = productB.safeDeliveryFee!!.value, deliveryMethod = SAFETY, ), ) @@ -215,7 +234,7 @@ class OrderServiceTest( Then("예외가 발생 한다") { shouldThrow { orderService.save(command) - }.exceptionType() shouldBe (ORDER_PRICE_NOT_MATCH) + }.exceptionType() shouldBe (ORDER_TOTAL_PRICE_NOT_MATCH) } } @@ -231,6 +250,9 @@ class OrderServiceTest( safeDeliveryFee = 5000.toBigDecimal(), ) ) + productSnapshotRepository.save(ProductSnapshot.from(productA)) + productSnapshotRepository.save(ProductSnapshot.from(productB)) + val productOptionA = productOptionRepository.save( productOption( productId = productA.id, @@ -281,7 +303,7 @@ class OrderServiceTest( Then("예외가 발생 한다") { shouldThrow { orderService.save(command) - }.exceptionType() shouldBe (ORDER_PRICE_NOT_MATCH) + }.exceptionType() shouldBe (PRODUCT_INFO_NOT_MATCH) } } @@ -305,6 +327,9 @@ class OrderServiceTest( safeDeliveryFee = 5000.toBigDecimal(), ) ) + productSnapshotRepository.save(ProductSnapshot.from(productA1)) + productSnapshotRepository.save(ProductSnapshot.from(productA2)) + productSnapshotRepository.save(ProductSnapshot.from(productB)) val productOptionA1 = productOptionRepository.save( productOption( @@ -374,7 +399,100 @@ class OrderServiceTest( Then("예외가 발생 한다") { shouldThrow { orderService.save(command) - }.exceptionType() shouldBe (ORDER_PRICE_NOT_MATCH) + }.exceptionType() shouldBe (ORDER_TOTAL_PRICE_NOT_MATCH) + } + } + + When("상품의 스냅샷이 존재하지 않으면") { + val memberId = memberRepository.save(member()).id + val productA1 = productRepository.save( + product( + storeId = 1L, + commonDeliveryFee = 3000.toBigDecimal(), + ) + ) + val productA2 = productRepository.save( + product( + storeId = 1L, + commonDeliveryFee = 3000.toBigDecimal(), + ) + ) + val productB = productRepository.save( + product( + storeId = 2L, + safeDeliveryFee = 5000.toBigDecimal(), + ) + ) + + val productOptionA1 = productOptionRepository.save( + productOption( + productId = productA1.id, + sex = FEMALE, + ) + ) + val productOptionA2 = productOptionRepository.save( + productOption( + productId = productA2.id, + sex = FEMALE, + ) + ) + val productOptionB = productOptionRepository.save( + productOption( + productId = productB.id, + sex = Sex.HERMAPHRODITE + ) + ) + val shippingAddress = shippingAddressRepository.save( + shippingAddress( + memberId = memberId, + ) + ) + val orderProductCommands = listOf( + orderProductCommand( + productId = productA1.id, + originalPrice = productA1.price.value, + discountRate = productA1.discountRate, + discountPrice = productA1.discountPrice.value, + sex = productOptionA1.sex, + orderPrice = BigDecimal.ONE, + additionalPrice = productOptionA1.additionalPrice.value, + deliveryFee = 3000.toBigDecimal(), + deliveryMethod = COMMON, + ), + orderProductCommand( + productId = productA2.id, + originalPrice = productA2.price.value, + discountRate = productA2.discountRate, + discountPrice = productA2.discountPrice.value, + sex = productOptionA2.sex, + orderPrice = BigDecimal.ONE, + additionalPrice = productOptionA2.additionalPrice.value, + deliveryFee = 3000.toBigDecimal(), + deliveryMethod = COMMON, + ), + orderProductCommand( + productId = productB.id, + originalPrice = productB.price.value, + discountRate = productB.discountRate, + discountPrice = productB.discountPrice.value, + sex = productOptionB.sex, + orderPrice = BigDecimal.ONE, + additionalPrice = productOptionB.additionalPrice.value, + deliveryFee = 5000.toBigDecimal(), + deliveryMethod = SAFETY, + ), + ) + val command = saveOrderCommand( + memberId = memberId, + orderProductCommands = orderProductCommands, + shippingAddressId = shippingAddress.id, + totalAmount = 8003.toBigDecimal(), + ) + + Then("예외가 발생 한다") { + shouldThrow { + orderService.save(command) + }.exceptionType() shouldBe (NOT_FOUND_PRODUCT) } } } @@ -403,6 +521,9 @@ class OrderServiceTest( safeDeliveryFee = 5000.toBigDecimal(), ) ) + productSnapshotRepository.save(ProductSnapshot.from(productA1)) + productSnapshotRepository.save(ProductSnapshot.from(productA2)) + productSnapshotRepository.save(ProductSnapshot.from(productB)) val productOptionA1 = productOptionRepository.save( productOption( @@ -475,9 +596,14 @@ class OrderServiceTest( orders.forAll { it.orderNumber.value shouldBe response.orderId it.orderName.value shouldBe response.orderName - it.status shouldBe ORDER_CREATED } orders.distinctBy { it.orderProduct.shippingNumber }.size shouldBe 2 + + val orderIds = orders.map { it.id } + val orderPayments = orderPaymentRepository.findLatestAllByOrderIds(orderIds) + orderPayments.forAll { + it.status shouldBe ORDER_CREATED + } } } } diff --git a/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt b/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt index 81b53e30..b8f26f93 100644 --- a/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/payment/PaymentFacadeServiceTest.kt @@ -2,14 +2,15 @@ package com.petqua.application.payment import com.ninjasquad.springmockk.SpykBean import com.petqua.application.payment.infra.TossPaymentsApiClient -import com.petqua.common.domain.findByIdOrThrow import com.petqua.domain.member.MemberRepository import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPayment import com.petqua.domain.order.OrderPaymentRepository import com.petqua.domain.order.OrderRepository import com.petqua.domain.order.OrderStatus.CANCELED import com.petqua.domain.order.OrderStatus.ORDER_CREATED import com.petqua.domain.order.OrderStatus.PAYMENT_CONFIRMED +import com.petqua.domain.order.findLatestByOrderIdOrThrow import com.petqua.domain.payment.tosspayment.TossPaymentRepository import com.petqua.exception.order.OrderException import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER @@ -27,6 +28,7 @@ import com.petqua.test.DataCleaner import com.petqua.test.fixture.failPaymentCommand import com.petqua.test.fixture.member import com.petqua.test.fixture.order +import com.petqua.test.fixture.orderPayment import com.petqua.test.fixture.succeedPaymentCommand import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow @@ -60,6 +62,7 @@ class PaymentFacadeServiceTest( totalAmount = ONE ) ) + orderPaymentRepository.save(OrderPayment.from(order)) When("유효한 요쳥이면") { paymentFacadeService.succeedPayment( @@ -99,10 +102,10 @@ class PaymentFacadeServiceTest( } Then("Order의 상태를 변경한다") { - val updatedOrder = orderRepository.findByIdOrThrow(order.id) + val updatedOrderPayment = orderPaymentRepository.findLatestByOrderIdOrThrow(order.id) assertSoftly { - updatedOrder.status shouldBe PAYMENT_CONFIRMED + updatedOrderPayment.status shouldBe PAYMENT_CONFIRMED } } @@ -110,8 +113,8 @@ class PaymentFacadeServiceTest( val orderPayments = orderPaymentRepository.findAll() assertSoftly { - orderPayments.size shouldBe 1 - val orderPayment = orderPayments[0] + orderPayments.size shouldBe 2 + val orderPayment = orderPayments[1] orderPayment.orderId shouldBe order.id orderPayment.tossPaymentId shouldBe paymentRepository.findAll()[0].id @@ -141,6 +144,11 @@ class PaymentFacadeServiceTest( memberId = member.id, orderNumber = OrderNumber.from("202402211607021ORDERNUMBER"), totalAmount = ONE, + ) + ) + orderPaymentRepository.save( + orderPayment( + orderId = invalidOrder.id, status = PAYMENT_CONFIRMED, ) ) @@ -224,6 +232,7 @@ class PaymentFacadeServiceTest( totalAmount = ONE ) ) + orderPaymentRepository.save(OrderPayment.from(order)) When("유효한 실패 내역이 입력되면") { val response = paymentFacadeService.failPayment( @@ -243,9 +252,9 @@ class PaymentFacadeServiceTest( } Then("주문을 취소하지 않는다") { - val updatedOrder = orderRepository.findByIdOrThrow(order.id) + val updatedOrderPayment = orderPaymentRepository.findLatestByOrderIdOrThrow(order.id) - updatedOrder.status shouldBe ORDER_CREATED + updatedOrderPayment.status shouldBe ORDER_CREATED } } @@ -276,6 +285,7 @@ class PaymentFacadeServiceTest( totalAmount = ONE ) ) + orderPaymentRepository.save(OrderPayment.from(order)) When("유효한 실패 내역이 입력되면") { val response = paymentFacadeService.failPayment( @@ -295,9 +305,9 @@ class PaymentFacadeServiceTest( } Then("주문을 취소한다") { - val updatedOrder = orderRepository.findByIdOrThrow(order.id) + val updatedOrderPayment = orderPaymentRepository.findLatestByOrderIdOrThrow(order.id) - updatedOrder.status shouldBe CANCELED + updatedOrderPayment.status shouldBe CANCELED } } diff --git a/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt b/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt index ac8ead37..0725c964 100644 --- a/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt @@ -149,6 +149,7 @@ class ProductServiceTest( Then("회원의 찜 여부와 함께 상품 상세정보를 반환한다") { productDetailResponse shouldBe productDetailResponse( product = product, + storeId = store.id, storeName = store.name, imageUrls = listOf(productImage.imageUrl), productDescription = productDescription, @@ -184,6 +185,7 @@ class ProductServiceTest( Then("찜 여부는 false 로 상품 상세정보를 반환한다") { productDetailResponse shouldBe productDetailResponse( product = product, + storeId = store.id, storeName = store.name, imageUrls = listOf(productImage.imageUrl), productDescription = productDescription, diff --git a/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt new file mode 100644 index 00000000..a47ffb92 --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt @@ -0,0 +1,69 @@ +package com.petqua.domain.order + +import com.petqua.exception.order.OrderPaymentException +import com.petqua.exception.order.OrderPaymentExceptionType.ORDER_PAYMENT_NOT_FOUND +import com.petqua.test.DataCleaner +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import kotlin.Long.Companion.MIN_VALUE + +@SpringBootTest(webEnvironment = NONE) +class OrderPaymentRepositoryTest( + private val orderPaymentRepository: OrderPaymentRepository, + private val dataCleaner: DataCleaner, +) : BehaviorSpec({ + + Given("주문번호로 최신 OrderPayment 를 조회할 때") { + val orderId = 1L + orderPaymentRepository.saveAll( + listOf( + OrderPayment( + id = 1L, + orderId = orderId, + prevId = null, + ), + OrderPayment( + id = 2L, + orderId = orderId, + prevId = 1L, + ), + OrderPayment( + id = 3L, + orderId = orderId, + prevId = 2L, + ) + ) + ) + + When("주문번호를 입력하면") { + val latestOrderPayment = orderPaymentRepository.findLatestByOrderIdOrThrow(orderId) { + OrderPaymentException(ORDER_PAYMENT_NOT_FOUND) + } + + Then("최신 OrderPayment 를 반환한다") { + latestOrderPayment.id shouldBe 3L + latestOrderPayment.prevId shouldBe 2L + latestOrderPayment.orderId shouldBe orderId + } + } + + When("존재하지 않는 주문번호를 입력하면") { + + Then("예외를 던진다") { + shouldThrow { + orderPaymentRepository.findLatestByOrderIdOrThrow(MIN_VALUE) { + OrderPaymentException(ORDER_PAYMENT_NOT_FOUND) + } + }.exceptionType() shouldBe ORDER_PAYMENT_NOT_FOUND + } + } + } + + afterContainer { + dataCleaner.clean() + } +}) + diff --git a/src/test/kotlin/com/petqua/domain/order/OrderPaymentTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderPaymentTest.kt new file mode 100644 index 00000000..0af5336a --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/order/OrderPaymentTest.kt @@ -0,0 +1,48 @@ +package com.petqua.domain.order + +import com.petqua.domain.order.OrderStatus.CANCELED +import com.petqua.domain.order.OrderStatus.PAYMENT_CONFIRMED +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType +import com.petqua.test.fixture.orderPayment +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class OrderPaymentTest : StringSpec({ + + "결제를 진행한다" { + val orderPayment = orderPayment() + val payedOrderPayment = orderPayment.pay(tossPaymentId = 1L) + + assertSoftly(payedOrderPayment) { + orderId shouldBe orderPayment.orderId + tossPaymentId shouldBe 1L + status shouldBe PAYMENT_CONFIRMED + prevId shouldBe orderPayment.id + } + } + + "결제 시도시 결제가 가능한 상태가 아니라면 예외를 던진다" { + val orderPayment = orderPayment( + status = PAYMENT_CONFIRMED + ) + + shouldThrow { + orderPayment.pay(tossPaymentId = 1L) + }.exceptionType() shouldBe OrderExceptionType.ORDER_CAN_NOT_PAY + } + + "주문을 취소한다" { + val orderPayment = orderPayment() + val payedOrderPayment = orderPayment.cancel() + + assertSoftly(payedOrderPayment) { + orderId shouldBe orderPayment.orderId + tossPaymentId shouldBe orderPayment.tossPaymentId + status shouldBe CANCELED + prevId shouldBe orderPayment.id + } + } +}) diff --git a/src/test/kotlin/com/petqua/domain/order/OrderTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderTest.kt index 17176be1..3abb19e9 100644 --- a/src/test/kotlin/com/petqua/domain/order/OrderTest.kt +++ b/src/test/kotlin/com/petqua/domain/order/OrderTest.kt @@ -1,11 +1,8 @@ package com.petqua.domain.order import com.petqua.common.domain.Money -import com.petqua.domain.order.OrderStatus.CANCELED -import com.petqua.domain.order.OrderStatus.ORDER_CREATED import com.petqua.exception.order.OrderException import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER -import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY import com.petqua.exception.order.OrderExceptionType.PAYMENT_PRICE_NOT_MATCH import com.petqua.test.fixture.order import io.kotest.assertions.throwables.shouldNotThrow @@ -57,24 +54,4 @@ class OrderTest : StringSpec({ order.validateOwner(MIN_VALUE) }.exceptionType() shouldBe FORBIDDEN_ORDER } - - "결제 처리를 한다" { - val order = order( - status = ORDER_CREATED - ) - - shouldNotThrow { - order.pay() - } - } - - "결제 처리 시 결제를 할 수 없다면 예외를 던진다" { - val order = order( - status = CANCELED - ) - - shouldThrow { - order.pay() - }.exceptionType() shouldBe ORDER_CAN_NOT_PAY - } }) diff --git a/src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt b/src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt index 38b4faf0..1b0139d7 100644 --- a/src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt +++ b/src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt @@ -40,11 +40,11 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest import java.math.BigDecimal.ONE import java.math.BigDecimal.TEN import java.math.BigDecimal.ZERO import kotlin.Long.Companion.MIN_VALUE -import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class ProductCustomRepositoryImplTest( @@ -135,7 +135,7 @@ class ProductCustomRepositoryImplTest( Then("입력한 Id의 상품과 상세정보가 반환된다") { productWithInfoResponse shouldBe ProductWithInfoResponse( product = product1, - storeName = store.name, + store = store, productDescription = ProductDescriptionResponse( title = productDescription1.title.value, content = productDescription1.content.value @@ -154,7 +154,7 @@ class ProductCustomRepositoryImplTest( Then("상세 설명이 없이 입력한 Id의 상품과 상세정보가 반환된다") { productWithInfoResponse shouldBe ProductWithInfoResponse( product = product2, - storeName = store.name, + store = store, productDescription = ProductDescriptionResponse( title = "", content = "" diff --git a/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt b/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt new file mode 100644 index 00000000..968c75dd --- /dev/null +++ b/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt @@ -0,0 +1,27 @@ +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() + } +} diff --git a/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt b/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt new file mode 100644 index 00000000..fa20d4b5 --- /dev/null +++ b/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt @@ -0,0 +1,563 @@ +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_TOTAL_PRICE_NOT_MATCH +import com.petqua.exception.order.OrderExceptionType.PRODUCT_INFO_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), + ) + + requestSaveOrder(request, accessToken) + + Then("배송번호는 두 개 생성된다") { + val orders = orderRepository.findAll() + + orders.distinctBy { it.orderProduct.shippingNumber }.size shouldBe 2 + } + } + + 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))[0] + + 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 PRODUCT_INFO_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 PRODUCT_INFO_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_TOTAL_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 PRODUCT_INFO_NOT_MATCH.errorMessage() + } + } + } + } + } +} diff --git a/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt b/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt index 5b04a327..e54f1685 100644 --- a/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt +++ b/src/test/kotlin/com/petqua/presentation/payment/PaymentControllerTest.kt @@ -4,12 +4,13 @@ import com.ninjasquad.springmockk.SpykBean import com.petqua.application.payment.FailPaymentResponse import com.petqua.application.payment.PaymentConfirmRequestToPG import com.petqua.application.payment.infra.TossPaymentsApiClient -import com.petqua.common.domain.findByIdOrThrow import com.petqua.common.exception.ExceptionResponse import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPayment import com.petqua.domain.order.OrderPaymentRepository import com.petqua.domain.order.OrderRepository import com.petqua.domain.order.OrderStatus +import com.petqua.domain.order.findLatestByOrderIdOrThrow import com.petqua.domain.payment.tosspayment.TossPaymentRepository import com.petqua.exception.order.OrderExceptionType.FORBIDDEN_ORDER import com.petqua.exception.order.OrderExceptionType.ORDER_CAN_NOT_PAY @@ -22,6 +23,7 @@ import com.petqua.exception.payment.FailPaymentExceptionType.ORDER_NUMBER_MISSIN import com.petqua.exception.payment.PaymentExceptionType.UNAUTHORIZED_KEY import com.petqua.test.ApiTestConfig import com.petqua.test.fixture.order +import com.petqua.test.fixture.orderPayment import com.petqua.test.fixture.succeedPaymentRequest import io.kotest.assertions.assertSoftly import io.kotest.matchers.shouldBe @@ -56,6 +58,7 @@ class PaymentControllerTest( totalAmount = ONE ) ) + orderPaymentRepository.save(OrderPayment.from(order)) When("유효한 요청이면") { val response = requestSucceedPayment( @@ -83,10 +86,10 @@ class PaymentControllerTest( } Then("Order의 상태를 변경한다") { - val updatedOrder = orderRepository.findByIdOrThrow(order.id) + val updatedOrderPayment = orderPaymentRepository.findLatestByOrderIdOrThrow(order.id) assertSoftly { - updatedOrder.status shouldBe OrderStatus.PAYMENT_CONFIRMED + updatedOrderPayment.status shouldBe OrderStatus.PAYMENT_CONFIRMED } } @@ -94,8 +97,8 @@ class PaymentControllerTest( val orderPayments = orderPaymentRepository.findAll() assertSoftly { - orderPayments.size shouldBe 1 - val payment = orderPayments[0] + orderPayments.size shouldBe 2 + val payment = orderPayments[1] payment.orderId shouldBe order.id payment.tossPaymentId shouldBe paymentRepository.findAll()[0].id @@ -130,7 +133,12 @@ class PaymentControllerTest( memberId = memberId, orderNumber = OrderNumber.from("202402211607021ORDERNUMBER"), totalAmount = ONE, - status = OrderStatus.PAYMENT_CONFIRMED, + ) + ) + orderPaymentRepository.save( + orderPayment( + orderId = invalidOrder.id, + status = OrderStatus.PAYMENT_CONFIRMED ) ) @@ -289,6 +297,7 @@ class PaymentControllerTest( totalAmount = ONE ) ) + orderPaymentRepository.save(OrderPayment.from(order)) When("유효한 실패 내역이 입력되면") { val response = requestFailPayment( diff --git a/src/test/kotlin/com/petqua/presentation/product/ProductControllerTest.kt b/src/test/kotlin/com/petqua/presentation/product/ProductControllerTest.kt index 92eef60f..33510bea 100644 --- a/src/test/kotlin/com/petqua/presentation/product/ProductControllerTest.kt +++ b/src/test/kotlin/com/petqua/presentation/product/ProductControllerTest.kt @@ -50,13 +50,13 @@ import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.restassured.common.mapper.TypeRef +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.NOT_FOUND import java.math.BigDecimal.ONE import java.math.BigDecimal.TEN import java.math.BigDecimal.ZERO import kotlin.Long.Companion.MIN_VALUE -import org.springframework.http.HttpStatus -import org.springframework.http.HttpStatus.BAD_REQUEST -import org.springframework.http.HttpStatus.NOT_FOUND class ProductControllerTest( private val productRepository: ProductRepository, @@ -161,6 +161,7 @@ class ProductControllerTest( response.statusCode shouldBe HttpStatus.OK.value() productDetailResponse shouldBe productDetailResponse( product = product, + storeId = store.id, storeName = store.name, imageUrls = listOf(productImage.imageUrl), productDescription = productDescription, @@ -203,6 +204,7 @@ class ProductControllerTest( response.statusCode shouldBe HttpStatus.OK.value() productDetailResponse shouldBe productDetailResponse( product = product, + storeId = store.id, storeName = store.name, imageUrls = listOf(productImage.imageUrl), productDescription = productDescription, diff --git a/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt b/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt index 2efe70bc..af7d91a8 100644 --- a/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt +++ b/src/test/kotlin/com/petqua/test/fixture/OrderFixture.kt @@ -11,10 +11,13 @@ 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 @@ -46,8 +49,6 @@ fun order( storeName: String = "storeName", deliveryMethod: DeliveryMethod = SAFETY, sex: Sex = FEMALE, - isAbleToCancel: Boolean = true, - status: OrderStatus = OrderStatus.ORDER_CREATED, totalAmount: BigDecimal = orderPrice + deliveryFee, ): Order { return Order( @@ -79,8 +80,6 @@ fun order( deliveryMethod = deliveryMethod, sex = sex, ), - isAbleToCancel = isAbleToCancel, - status = status, totalAmount = Money.from(totalAmount), ) } @@ -178,3 +177,68 @@ fun orderProductCommand( deliveryMethod = deliveryMethod, ) } + +fun saveOrderRequest( + shippingAddressId: Long? = null, + shippingRequest: String? = null, + orderProductRequests: List, + 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 + ) +} diff --git a/src/test/kotlin/com/petqua/test/fixture/OrderPaymentFixture.kt b/src/test/kotlin/com/petqua/test/fixture/OrderPaymentFixture.kt new file mode 100644 index 00000000..ad60dcd0 --- /dev/null +++ b/src/test/kotlin/com/petqua/test/fixture/OrderPaymentFixture.kt @@ -0,0 +1,21 @@ +package com.petqua.test.fixture + +import com.petqua.domain.order.OrderPayment +import com.petqua.domain.order.OrderStatus +import com.petqua.domain.order.OrderStatus.ORDER_CREATED + +fun orderPayment( + id: Long = 0L, + orderId: Long = 0L, + tossPaymentId: Long = 0L, + status: OrderStatus = ORDER_CREATED, + prevId: Long = 0L, +): OrderPayment { + return OrderPayment( + id = id, + orderId = orderId, + tossPaymentId = tossPaymentId, + status = status, + prevId = prevId, + ) +} diff --git a/src/test/kotlin/com/petqua/test/fixture/ProductFixtures.kt b/src/test/kotlin/com/petqua/test/fixture/ProductFixtures.kt index 23c63b5b..96eff7f1 100644 --- a/src/test/kotlin/com/petqua/test/fixture/ProductFixtures.kt +++ b/src/test/kotlin/com/petqua/test/fixture/ProductFixtures.kt @@ -134,6 +134,7 @@ fun productDescription( fun productDetailResponse( product: Product, + storeId: Long, storeName: String, imageUrls: List, productDescription: ProductDescription, @@ -150,6 +151,7 @@ fun productDetailResponse( family = category.family.name, species = category.species.name, price = product.price, + storeId = storeId, storeName = storeName, discountRate = product.discountRate, discountPrice = product.discountPrice,