diff --git a/src/main/kotlin/com/petqua/application/order/OrderService.kt b/src/main/kotlin/com/petqua/application/order/OrderService.kt index 2fdfb34e..8c68352c 100644 --- a/src/main/kotlin/com/petqua/application/order/OrderService.kt +++ b/src/main/kotlin/com/petqua/application/order/OrderService.kt @@ -2,9 +2,11 @@ package com.petqua.application.order import com.petqua.application.order.dto.OrderDetailReadQuery import com.petqua.application.order.dto.OrderProductCommand +import com.petqua.application.order.dto.OrderReadQuery 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.dto.DEFAULT_LAST_VIEWED_ID import com.petqua.common.domain.findByIdOrThrow import com.petqua.common.util.getOrThrow import com.petqua.common.util.throwExceptionWhen @@ -16,6 +18,7 @@ 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 import com.petqua.domain.order.ShippingAddress import com.petqua.domain.order.ShippingAddressRepository import com.petqua.domain.order.ShippingNumber @@ -28,6 +31,7 @@ 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.EMPTY_SHIPPING_ADDRESS +import com.petqua.exception.order.OrderExceptionType.NOT_INVALID_ORDER_READ_QUERY import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND import com.petqua.exception.order.OrderExceptionType.PRODUCT_NOT_FOUND import com.petqua.exception.order.OrderExceptionType.STORE_NOT_FOUND @@ -37,6 +41,7 @@ import com.petqua.exception.product.ProductException import com.petqua.exception.product.ProductExceptionType.NOT_FOUND_PRODUCT import com.petqua.presentation.order.dto.OrderDetailResponse import com.petqua.presentation.order.dto.OrderProductResponse +import com.petqua.presentation.order.dto.OrdersResponse import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -151,9 +156,51 @@ class OrderService( } private fun orderProductResponsesFromOrders(orders: List): List { - val statusByOrderId = orders.map { orderPaymentRepository.findOrderStatusByOrderId(it.id) } - .associateBy { orderPayment -> orderPayment.orderId } - .mapValues { it.value.status } - return orders.map { OrderProductResponse(it, statusByOrderId.getOrThrow(it.id)) } + val orderStatusByOrderId = orderStatusByOrders(orders) + return orders.map { OrderProductResponse(it, orderStatusByOrderId.getOrThrow(it.id)) } + } + + @Transactional(readOnly = true) + fun readAll(query: OrderReadQuery): OrdersResponse { + validateOrderReadQuery(query) + val orders = orderRepository.findOrdersByMemberId(query.memberId, query.toOrderPaging()) + val ordersByOrderNumber = orders.groupBy { it.orderNumber } + val orderDetails = ordersByOrderNumber.mapValues { + orderDetailResponseFromOrders(it.value) + } + return OrdersResponse.of(orderDetails.values.toList(), query.limit) + } + + private fun validateOrderReadQuery(query: OrderReadQuery) { + if (query.lastViewedId == DEFAULT_LAST_VIEWED_ID) { + return + } + + val order = orderRepository.findByIdOrThrow(query.lastViewedId) + throwExceptionWhen(order.orderNumber != query.lastViewedOrderNumber) { + throw OrderException(NOT_INVALID_ORDER_READ_QUERY) + } + } + + private fun orderDetailResponseFromOrders(orders: List): OrderDetailResponse { + val orderStatusByOrderId = orderStatusByOrders(orders) + val representativeOrder = orders[0] + val orderProductResponses = orders.map { + val orderStatus = orderStatusByOrderId.getOrThrow(it.id) + OrderProductResponse(it, orderStatus) + } + + return OrderDetailResponse( + orderNumber = representativeOrder.orderNumber.value, + orderedAt = representativeOrder.createdAt, + orderProducts = orderProductResponses, + totalAmount = representativeOrder.totalAmount, + ) + } + + private fun orderStatusByOrders(orders: List): Map { + val orderIds = orders.map { it.id } + return orderPaymentRepository.findOrderStatusByOrderIds(orderIds) + .associateBy({ it.orderId }, { it.status }) } } 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 ae4f0690..361350ae 100644 --- a/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt +++ b/src/main/kotlin/com/petqua/application/order/dto/OrderDtos.kt @@ -1,13 +1,18 @@ package com.petqua.application.order.dto import com.petqua.common.domain.Money +import com.petqua.common.domain.dto.DEFAULT_LAST_VIEWED_ID +import com.petqua.common.util.throwExceptionWhen import com.petqua.domain.delivery.DeliveryMethod import com.petqua.domain.order.OrderNumber +import com.petqua.domain.order.OrderPaging import com.petqua.domain.order.OrderProduct import com.petqua.domain.order.ShippingNumber import com.petqua.domain.product.ProductSnapshot import com.petqua.domain.product.option.ProductOption import com.petqua.domain.product.option.Sex +import com.petqua.exception.order.OrderException +import com.petqua.exception.order.OrderExceptionType.NOT_INVALID_ORDER_READ_QUERY import io.swagger.v3.oas.annotations.media.Schema data class SaveOrderCommand( @@ -91,3 +96,43 @@ data class OrderDetailReadQuery( } } } + + +data class OrderReadQuery internal constructor( + val memberId: Long, + val lastViewedId: Long, + val limit: Int, + val lastViewedOrderNumber: OrderNumber?, +) { + + companion object { + fun of( + memberId: Long, + lastViewedId: Long, + limit: Int, + lastViewedOrderNumber: String? + ): OrderReadQuery { + validateLastViewedIdAndOrderNumber(lastViewedId, lastViewedOrderNumber) + return OrderReadQuery( + memberId = memberId, + lastViewedId = lastViewedId, + limit = limit, + lastViewedOrderNumber = lastViewedOrderNumber?.let { OrderNumber(it) }, + ) + } + + private fun validateLastViewedIdAndOrderNumber(lastViewedId: Long, lastViewedOrderNumber: String?) { + throwExceptionWhen(lastViewedId == DEFAULT_LAST_VIEWED_ID && lastViewedOrderNumber != null) { + throw OrderException(NOT_INVALID_ORDER_READ_QUERY) + } + + throwExceptionWhen(lastViewedId != DEFAULT_LAST_VIEWED_ID && lastViewedOrderNumber == null) { + throw OrderException(NOT_INVALID_ORDER_READ_QUERY) + } + } + } + + fun toOrderPaging(): OrderPaging { + return OrderPaging.of(lastViewedId, limit, lastViewedOrderNumber) + } +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderCustomRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderCustomRepository.kt new file mode 100644 index 00000000..d9cee0ca --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderCustomRepository.kt @@ -0,0 +1,33 @@ +package com.petqua.domain.order + +import com.petqua.common.domain.dto.DEFAULT_LAST_VIEWED_ID +import com.petqua.common.domain.dto.PADDING_FOR_HAS_NEXT_PAGE + +private const val ORDER_PAGING_LIMIT_CEILING = 5 + +data class OrderPaging( + val lastViewedId: Long? = null, + val limit: Int = ORDER_PAGING_LIMIT_CEILING, + val lastViewedOrderNumber: OrderNumber? = null, +) { + + companion object { + fun of( + lastViewedId: Long, + limit: Int, + lastViewedOrderNumber: OrderNumber?, + ): OrderPaging { + val adjustedLastViewedId = if (lastViewedId == DEFAULT_LAST_VIEWED_ID) null else lastViewedId + val adjustedLimit = if (limit > ORDER_PAGING_LIMIT_CEILING) ORDER_PAGING_LIMIT_CEILING else limit + return OrderPaging(adjustedLastViewedId, adjustedLimit + PADDING_FOR_HAS_NEXT_PAGE, lastViewedOrderNumber) + } + } +} + +interface OrderCustomRepository { + + fun findOrdersByMemberId( + memberId: Long, + orderPaging: OrderPaging, + ): List +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderCustomRepositoryImpl.kt b/src/main/kotlin/com/petqua/domain/order/OrderCustomRepositoryImpl.kt new file mode 100644 index 00000000..38ffbe58 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderCustomRepositoryImpl.kt @@ -0,0 +1,71 @@ +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.createQuery +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Repository + + +@Repository +class OrderCustomRepositoryImpl( + private val entityManager: EntityManager, + private val jpqlRenderContext: JpqlRenderContext, + private val jpqlRenderer: JpqlRenderer, +) : OrderCustomRepository { + + override fun findOrdersByMemberId( + memberId: Long, + orderPaging: OrderPaging, + ): List { + val latestOrderNumbers = findLatestOrderNumbers(memberId, orderPaging) + if (latestOrderNumbers.isEmpty()) { + return emptyList() + } + + val query = jpql { + select( + entity(Order::class), + ).from( + entity(Order::class), + ).where( + path(Order::orderNumber)(OrderNumber::value).`in`(latestOrderNumbers), + ).orderBy( + path(Order::id).desc() + ) + } + + return entityManager.createQuery( + query, + jpqlRenderContext, + jpqlRenderer, + ) + } + + private fun findLatestOrderNumbers( + memberId: Long, + paging: OrderPaging, + ): List { + val query = jpql(OrderDynamicJpqlGenerator) { + selectDistinct( + path(Order::orderNumber)(OrderNumber::value) + ).from( + entity(Order::class) + ).whereAnd( + path(Order::memberId).eq(memberId), + orderIdLt(paging.lastViewedId), + orderNumberNotEq(paging.lastViewedOrderNumber), + ).orderBy( + path(Order::id).desc() + ) + } + + return entityManager.createQuery( + query, + jpqlRenderContext, + jpqlRenderer, + paging.limit, + ) + } +} diff --git a/src/main/kotlin/com/petqua/domain/order/OrderDynamicJpqlGenerator.kt b/src/main/kotlin/com/petqua/domain/order/OrderDynamicJpqlGenerator.kt new file mode 100644 index 00000000..5a342eb4 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/order/OrderDynamicJpqlGenerator.kt @@ -0,0 +1,20 @@ +package com.petqua.domain.order + +import com.linecorp.kotlinjdsl.dsl.jpql.Jpql +import com.linecorp.kotlinjdsl.dsl.jpql.JpqlDsl +import com.linecorp.kotlinjdsl.querymodel.jpql.predicate.Predicate + +class OrderDynamicJpqlGenerator : Jpql() { + companion object Constructor : JpqlDsl.Constructor { + override fun newInstance(): OrderDynamicJpqlGenerator = OrderDynamicJpqlGenerator() + } + + fun Jpql.orderNumberNotEq(orderNumber: OrderNumber?): Predicate? { + return orderNumber?.let { path(Order::orderNumber).notEqual(it) } + } + + fun Jpql.orderIdLt(lastViewedId: Long?): Predicate? { + return lastViewedId?.let { path(Order::id).lt(it) } + } +} + diff --git a/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt index 0902a8e6..21838501 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderPaymentRepository.kt @@ -34,4 +34,19 @@ interface OrderPaymentRepository : JpaRepository { @Query("SELECT op FROM OrderPayment op WHERE op.orderId = :orderId ORDER BY op.id DESC LIMIT 1") fun findOrderStatusByOrderId(orderId: Long): OrderPayment + + @Query( + """ + SELECT op + FROM OrderPayment op + WHERE op.id IN ( + SELECT MAX(op2.id) + FROM OrderPayment op2 + WHERE op2.orderId IN :orderIds + GROUP BY op2.orderId + ) + ORDER BY op.id DESC + """ + ) + fun findOrderStatusByOrderIds(orderIds: List): List } diff --git a/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt b/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt index a2384e72..cda3f80a 100644 --- a/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt +++ b/src/main/kotlin/com/petqua/domain/order/OrderRepository.kt @@ -10,7 +10,7 @@ fun OrderRepository.findByOrderNumberOrThrow( return orders.ifEmpty { throw exceptionSupplier() } } -interface OrderRepository : JpaRepository { +interface OrderRepository : JpaRepository, OrderCustomRepository { fun findByOrderNumber(orderNumber: OrderNumber): List } diff --git a/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt b/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt index 771cf352..c7fd6a7e 100644 --- a/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt +++ b/src/main/kotlin/com/petqua/exception/order/OrderExceptionType.kt @@ -26,6 +26,8 @@ enum class OrderExceptionType( FORBIDDEN_ORDER(FORBIDDEN, "O30", "해당 주문에 대한 권한이 없습니다."), ORDER_CAN_NOT_CANCEL(BAD_REQUEST, "O31", "취소할 수 없는 주문입니다."), ORDER_CAN_NOT_PAY(BAD_REQUEST, "O32", "결제할 수 없는 주문입니다."), + + NOT_INVALID_ORDER_READ_QUERY(BAD_REQUEST, "O40", "유효하지 않은 주문 조회 조건입니다."), ; override fun httpStatus(): HttpStatus { diff --git a/src/main/kotlin/com/petqua/presentation/order/OrderController.kt b/src/main/kotlin/com/petqua/presentation/order/OrderController.kt index 83f2114a..ac73a563 100644 --- a/src/main/kotlin/com/petqua/presentation/order/OrderController.kt +++ b/src/main/kotlin/com/petqua/presentation/order/OrderController.kt @@ -7,6 +7,8 @@ import com.petqua.common.config.ACCESS_TOKEN_SECURITY_SCHEME_KEY import com.petqua.domain.auth.Auth import com.petqua.domain.auth.LoginMember import com.petqua.presentation.order.dto.OrderDetailResponse +import com.petqua.presentation.order.dto.OrderReadRequest +import com.petqua.presentation.order.dto.OrdersResponse import com.petqua.presentation.order.dto.SaveOrderRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -41,7 +43,7 @@ class OrderController( @Operation(summary = "주문 상세 조회 API", description = "주문 상세를 조회합니다") @ApiResponse(responseCode = "200", description = "주문 상세 조회 성공") - @GetMapping + @GetMapping("/detail") fun readDetail( @Auth loginMember: LoginMember, @RequestParam orderNumber: String, @@ -50,4 +52,16 @@ class OrderController( val response = orderService.readDetail(query) return ResponseEntity.ok(response) } + + @Operation(summary = "주문 내역 조회 API", description = "주문 내역을 조회합니다") + @ApiResponse(responseCode = "200", description = "주문 내역 조회 성공") + @GetMapping + fun readAll( + @Auth loginMember: LoginMember, + request: OrderReadRequest, + ): ResponseEntity { + val query = request.toQuery(loginMember) + val response = orderService.readAll(query) + return ResponseEntity.ok(response) + } } 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 6aa7f6cb..9807c37d 100644 --- a/src/main/kotlin/com/petqua/presentation/order/dto/OrderDtos.kt +++ b/src/main/kotlin/com/petqua/presentation/order/dto/OrderDtos.kt @@ -1,8 +1,12 @@ package com.petqua.presentation.order.dto import com.petqua.application.order.dto.OrderProductCommand +import com.petqua.application.order.dto.OrderReadQuery import com.petqua.application.order.dto.SaveOrderCommand import com.petqua.common.domain.Money +import com.petqua.common.domain.dto.DEFAULT_LAST_VIEWED_ID +import com.petqua.common.domain.dto.PAGING_LIMIT_CEILING +import com.petqua.domain.auth.LoginMember import com.petqua.domain.delivery.DeliveryMethod import com.petqua.domain.order.Order import com.petqua.domain.order.OrderStatus @@ -258,3 +262,63 @@ data class OrderProductResponse( deliveryMethod = order.orderProduct.deliveryMethod.name, ) } + +data class OrdersResponse( + val orders: List, + + @Schema( + description = "다음 페이지 존재 여부", + example = "true" + ) + val hasNextPage: Boolean, +) { + + companion object { + fun of(orders: List, limit: Int): OrdersResponse { + return if (orders.size > limit) { + OrdersResponse(orders.dropLast(1), hasNextPage = true) + } else { + OrdersResponse(orders, hasNextPage = false) + } + } + } +} + +const val INITIAL_READ_ORDER_NUMBER = "EMPTY" + +data class OrderReadRequest( + @Schema( + description = "마지막으로 조회한 주문의 Id. 없을 경우 -1", + example = "1" + ) + val lastViewedId: Long = DEFAULT_LAST_VIEWED_ID, + + @Schema( + description = "조회할 주문의 개수", + defaultValue = "20" + ) + val limit: Int = PAGING_LIMIT_CEILING, + + @Schema( + description = "마지막으로 조회한 주문 번호. 없을 경우 EMPTY", + example = "20210901000001" + ) + val lastViewedOrderNumber: String, +) { + + fun toQuery(loginMember: LoginMember): OrderReadQuery { + return OrderReadQuery.of( + memberId = loginMember.memberId, + lastViewedId = lastViewedId, + limit = limit, + lastViewedOrderNumber = adjustInitialReadOrderNumber(lastViewedOrderNumber), + ) + } + + private fun adjustInitialReadOrderNumber(lastViewedOrderNumber: String) = + if (lastViewedOrderNumber == INITIAL_READ_ORDER_NUMBER) { + null + } else { + lastViewedOrderNumber + } +} diff --git a/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt b/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt index a74b790f..29d75365 100644 --- a/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/order/OrderServiceTest.kt @@ -1,6 +1,8 @@ package com.petqua.application.order import com.petqua.application.order.dto.OrderDetailReadQuery +import com.petqua.application.order.dto.OrderReadQuery +import com.petqua.common.domain.dto.DEFAULT_LAST_VIEWED_ID import com.petqua.domain.delivery.DeliveryMethod.COMMON import com.petqua.domain.delivery.DeliveryMethod.SAFETY import com.petqua.domain.member.MemberRepository @@ -18,6 +20,7 @@ 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.FORBIDDEN_ORDER +import com.petqua.exception.order.OrderExceptionType.NOT_INVALID_ORDER_READ_QUERY import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND import com.petqua.exception.order.OrderExceptionType.ORDER_TOTAL_PRICE_NOT_MATCH import com.petqua.exception.order.OrderExceptionType.PRODUCT_INFO_NOT_MATCH @@ -29,6 +32,8 @@ 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.order +import com.petqua.test.fixture.orderPayment import com.petqua.test.fixture.orderProductCommand import com.petqua.test.fixture.product import com.petqua.test.fixture.productOption @@ -613,7 +618,7 @@ class OrderServiceTest( } } - Given("주문 상세 내역로 조회 시") { + Given("주문 상세 내역 조회 시") { val storeAId = storeRepository.save(store()).id val storeBId = storeRepository.save(store()).id val memberId = memberRepository.save(member()).id @@ -750,6 +755,95 @@ class OrderServiceTest( } } + Given("주문 내역 조회 시") { + val member = memberRepository.save(member()) + + val orderNumberA = OrderNumber.from("202202211607020ORDERNUMBER") + val orderA1 = order(memberId = member.id, orderNumber = orderNumberA, productName = "A1") + val orderA2 = order(memberId = member.id, orderNumber = orderNumberA, productName = "A2") + val orderA3 = order(memberId = member.id, orderNumber = orderNumberA, productName = "A3") + + val orderNumberB = OrderNumber.from("202302211607020ORDERNUMBER") + val orderB1 = order(memberId = member.id, orderNumber = orderNumberB, productName = "B1") + val orderB2 = order(memberId = member.id, orderNumber = orderNumberB, productName = "B2") + + + val orderNumberC = OrderNumber.from("202402211607020ORDERNUMBER") + val orderC1 = order(memberId = member.id, orderNumber = orderNumberC, productName = "C1") + + orderRepository.saveAll( + listOf( + orderA1, orderA2, orderA3, + orderB1, orderB2, + orderC1 + ) + ) + + orderPaymentRepository.saveAll( + listOf( + orderPayment(orderId = orderA1.id, prevId = orderA1.id), + orderPayment(orderId = orderA2.id, prevId = orderA2.id), + orderPayment(orderId = orderA3.id, prevId = orderA3.id), + orderPayment(orderId = orderB1.id, prevId = orderB1.id), + orderPayment(orderId = orderB2.id, prevId = orderB2.id), + orderPayment(orderId = orderC1.id, prevId = orderC1.id), + ) + ) + + When("최초 주문 조회시 주문ID와 주문번호는 입력 하지 않아도") { + val query = OrderReadQuery( + memberId = member.id, + lastViewedId = DEFAULT_LAST_VIEWED_ID, + limit = 2, + lastViewedOrderNumber = null, + ) + val result = orderService.readAll(query) + + Then("주문 내역이 조회된다.") { + assertSoftly(result) { + orders.size shouldBe 2 + orders[0].orderProducts.map { it.productName } shouldBe listOf("C1") + orders[1].orderProducts.map { it.productName } shouldBe listOf("B2", "B1") + hasNextPage shouldBe true + } + } + } + + When("마지막으로 조회된 주문의 ID와 주문 번호를 기준으로") { + val query = OrderReadQuery( + memberId = member.id, + lastViewedId = orderC1.id, + limit = 2, + lastViewedOrderNumber = orderNumberC, + ) + val result = orderService.readAll(query) + + Then("주문 내역이 조회된다.") { + assertSoftly(result) { + orders.size shouldBe 2 + orders[0].orderProducts.map { it.productName } shouldBe listOf("B2", "B1") + orders[1].orderProducts.map { it.productName } shouldBe listOf("A3", "A2", "A1") + hasNextPage shouldBe false + } + } + } + + When("조회 조건에 주문번호와 ID가 다르면") { + val query = OrderReadQuery( + memberId = member.id, + lastViewedId = orderC1.id, + limit = 2, + lastViewedOrderNumber = orderNumberA, + ) + + Then("예외가 발생 한다") { + shouldThrow { + orderService.readAll(query) + }.exceptionType() shouldBe (NOT_INVALID_ORDER_READ_QUERY) + } + } + } + afterContainer { dataCleaner.clean() } diff --git a/src/test/kotlin/com/petqua/domain/order/OrderCustomRepositoryImplTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderCustomRepositoryImplTest.kt new file mode 100644 index 00000000..bb4acd6b --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/order/OrderCustomRepositoryImplTest.kt @@ -0,0 +1,109 @@ +package com.petqua.domain.order + +import com.petqua.common.domain.dto.DEFAULT_LAST_VIEWED_ID +import com.petqua.domain.member.MemberRepository +import com.petqua.test.DataCleaner +import com.petqua.test.fixture.member +import com.petqua.test.fixture.order +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 + +@SpringBootTest(webEnvironment = NONE) +class OrderCustomRepositoryImplTest( + private val orderRepository: OrderRepository, + private val memberRepository: MemberRepository, + private val dataCleaner: DataCleaner, +) : BehaviorSpec({ + + /** + * 테스트 데이터 Order + * OrderA - A1, A2, A3 + * OrderB - B1, B2 + * OrderC - C1 + */ + Given("주문을 조회 할 때") { + val member = memberRepository.save(member()) + + val orderNumberA = OrderNumber.from("202202211607020ORDERNUMBER") + val orderA1 = order(memberId = member.id, orderNumber = orderNumberA, productName = "A1") + val orderA2 = order(memberId = member.id, orderNumber = orderNumberA, productName = "A2") + val orderA3 = order(memberId = member.id, orderNumber = orderNumberA, productName = "A3") + + val orderNumberB = OrderNumber.from("202302211607020ORDERNUMBER") + val orderB1 = order(memberId = member.id, orderNumber = orderNumberB, productName = "B1") + val orderB2 = order(memberId = member.id, orderNumber = orderNumberB, productName = "B2") + + val orderNumberC = OrderNumber.from("202402211607020ORDERNUMBER") + val orderC1 = order(memberId = member.id, orderNumber = orderNumberC, productName = "C1") + + orderRepository.saveAll( + listOf( + orderA1, orderA2, orderA3, + orderB1, orderB2, + orderC1 + ) + ) + + When("최초 주문 조회시 주문ID와 주문번호는 입력 하지 않아도") { + val orderPagingRequest = OrderPaging.of( + lastViewedId = DEFAULT_LAST_VIEWED_ID, // 최초 조회 플래그 + limit = 1, + lastViewedOrderNumber = null + ) + + val result = orderRepository.findOrdersByMemberId( + memberId = member.id, + orderPaging = orderPagingRequest, + ) + + Then("주문 내역이 조회된다.") { + result.forEach { println(it.orderProduct.productName) } + result.size shouldBe 3 + result.map { it.orderProduct.productName } shouldBe listOf("C1", "B2", "B1") + } + } + + When("마지막으로 조회된 주문의 ID와 주문 번호를 기준으로") { + val orderPagingRequest = OrderPaging.of( + lastViewedId = orderC1.id, + limit = 1, + lastViewedOrderNumber = orderNumberC + ) + + val result = orderRepository.findOrdersByMemberId( + memberId = member.id, + orderPaging = orderPagingRequest, + ) + + Then("주문 내역이 조회된다.") { + result.size shouldBe 5 + result.map { it.orderProduct.productName } shouldBe listOf("B2", "B1", "A3", "A2", "A1") + + } + } + + When("회원의 주문이 존재하지 않는 경우") { + val neverOrderedMember = memberRepository.save(member()) + val orderPagingRequest = OrderPaging.of( + lastViewedId = 1000, + limit = 1, + lastViewedOrderNumber = OrderNumber.from("202402211607020ORDERNUMBER") + ) + + val result = orderRepository.findOrdersByMemberId( + memberId = neverOrderedMember.id, + orderPaging = orderPagingRequest, + ) + + Then("빈 리스트가 반환된다.") { + result.size shouldBe 0 + } + } + } + + afterContainer { + dataCleaner.clean() + } +}) diff --git a/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt b/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt index a47ffb92..b4f94987 100644 --- a/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt +++ b/src/test/kotlin/com/petqua/domain/order/OrderPaymentRepositoryTest.kt @@ -1,14 +1,18 @@ package com.petqua.domain.order +import com.petqua.domain.order.OrderStatus.ORDER_CONFIRMED +import com.petqua.domain.order.OrderStatus.ORDER_CREATED +import com.petqua.domain.order.OrderStatus.PAYMENT_CONFIRMED import com.petqua.exception.order.OrderPaymentException import com.petqua.exception.order.OrderPaymentExceptionType.ORDER_PAYMENT_NOT_FOUND import com.petqua.test.DataCleaner +import com.petqua.test.fixture.orderPayment import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe +import kotlin.Long.Companion.MIN_VALUE 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( @@ -62,6 +66,35 @@ class OrderPaymentRepositoryTest( } } + Given("여러 주문번호로 가장 최신의 OrderPayment 조회 시") { + val orderIds = listOf(1L, 2L, 3L) + orderPaymentRepository.saveAll( + listOf( + orderPayment(orderId = 1L, prevId = 1L, status = ORDER_CREATED), + orderPayment(orderId = 1L, prevId = 2L, status = ORDER_CONFIRMED), + orderPayment(orderId = 2L, prevId = 3L, status = ORDER_CREATED), + orderPayment(orderId = 2L, prevId = 4L, status = PAYMENT_CONFIRMED), + orderPayment(orderId = 3L, prevId = 5L, status = ORDER_CREATED), + ) + ) + + When("주문번호를 입력하면") { + val latestOrderPayments = orderPaymentRepository.findOrderStatusByOrderIds(orderIds) + + Then("최신 OrderPayment 를 반환한다") { + latestOrderPayments.size shouldBe 3 + latestOrderPayments[0].orderId shouldBe 3L + latestOrderPayments[0].status shouldBe ORDER_CREATED + + latestOrderPayments[1].orderId shouldBe 2L + latestOrderPayments[1].status shouldBe PAYMENT_CONFIRMED + + latestOrderPayments[2].orderId shouldBe 1L + latestOrderPayments[2].status shouldBe ORDER_CONFIRMED + } + } + } + afterContainer { dataCleaner.clean() } diff --git a/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt b/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt index 37d4e232..94e6943b 100644 --- a/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt +++ b/src/test/kotlin/com/petqua/presentation/order/OrderControllerSteps.kt @@ -1,6 +1,7 @@ package com.petqua.presentation.order import com.petqua.application.order.dto.SaveOrderResponse +import com.petqua.presentation.order.dto.OrderReadRequest import com.petqua.presentation.order.dto.SaveOrderRequest import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given @@ -55,6 +56,27 @@ fun requestReadOrderDetail( log().all() auth().preemptive().oauth2(accessToken) .queryParams("orderNumber", orderNumber) + } When { + get("/orders/detail") + } Then { + log().all() + } Extract { + response() + } +} + +fun requestReadOrders( + accessToken: String, + request: OrderReadRequest, +): Response { + return Given { + log().all() + auth().preemptive().oauth2(accessToken) + .params( + "lastViewedId", request.lastViewedId, + "limit", request.limit, + "lastViewedOrderNumber", request.lastViewedOrderNumber, + ) } When { get("/orders") } Then { diff --git a/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt b/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt index ae053ade..d32856da 100644 --- a/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt +++ b/src/test/kotlin/com/petqua/presentation/order/OrderControllerTest.kt @@ -6,6 +6,7 @@ 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.OrderPaymentRepository import com.petqua.domain.order.OrderRepository import com.petqua.domain.order.OrderStatus.ORDER_CREATED import com.petqua.domain.order.ShippingAddressRepository @@ -18,14 +19,19 @@ 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.FORBIDDEN_ORDER +import com.petqua.exception.order.OrderExceptionType.NOT_INVALID_ORDER_READ_QUERY import com.petqua.exception.order.OrderExceptionType.ORDER_NOT_FOUND 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.presentation.order.dto.INITIAL_READ_ORDER_NUMBER import com.petqua.presentation.order.dto.OrderDetailResponse +import com.petqua.presentation.order.dto.OrderReadRequest import com.petqua.test.ApiTestConfig +import com.petqua.test.fixture.order +import com.petqua.test.fixture.orderPayment import com.petqua.test.fixture.orderProductRequest import com.petqua.test.fixture.product import com.petqua.test.fixture.productOption @@ -39,10 +45,10 @@ import io.kotest.matchers.shouldBe import java.math.BigDecimal.ONE 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.FORBIDDEN import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.http.HttpStatus.OK class OrderControllerTest( private val orderRepository: OrderRepository, @@ -51,6 +57,7 @@ class OrderControllerTest( private val productOptionRepository: ProductOptionRepository, private val productSnapshotRepository: ProductSnapshotRepository, private val shippingAddressRepository: ShippingAddressRepository, + private val orderPaymentRepository: OrderPaymentRepository, ) : ApiTestConfig() { init { @@ -568,7 +575,7 @@ class OrderControllerTest( } } - Given("주문 내역을 조회 할 때") { + Given("주문 상세 내역을 조회 할 때") { val accessToken = signInAsMember().accessToken val memberId = getMemberIdByAccessToken(accessToken) @@ -675,7 +682,7 @@ class OrderControllerTest( Then("주문 내역이 조회된다") { val responseBody = response.`as`(OrderDetailResponse::class.java) assertSoftly(response) { - statusCode shouldBe HttpStatus.OK.value() + statusCode shouldBe OK.value() responseBody.orderProducts should { products -> products.size shouldBe 2 products.forAll { it.orderStatus shouldBe ORDER_CREATED.name } @@ -710,5 +717,145 @@ class OrderControllerTest( } } } + + Given("주문 내역을 조회 할 때") { + val accessToken = signInAsMember().accessToken + val memberId = getMemberIdByAccessToken(accessToken) + + val orderNumberA = OrderNumber.from("202202211607020ORDERNUMBER") + val orderA1 = order(memberId = memberId, orderNumber = orderNumberA, productName = "A1") + val orderA2 = order(memberId = memberId, orderNumber = orderNumberA, productName = "A2") + val orderA3 = order(memberId = memberId, orderNumber = orderNumberA, productName = "A3") + + val orderNumberB = OrderNumber.from("202302211607020ORDERNUMBER") + val orderB1 = order(memberId = memberId, orderNumber = orderNumberB, productName = "B1") + val orderB2 = order(memberId = memberId, orderNumber = orderNumberB, productName = "B2") + + + val orderNumberC = OrderNumber.from("202402211607020ORDERNUMBER") + val orderC1 = order(memberId = memberId, orderNumber = orderNumberC, productName = "C1") + + orderRepository.saveAll( + listOf( + orderA1, orderA2, orderA3, + orderB1, orderB2, + orderC1 + ) + ) + + orderPaymentRepository.saveAll( + listOf( + orderPayment(orderId = orderA1.id, prevId = orderA1.id), + orderPayment(orderId = orderA2.id, prevId = orderA2.id), + orderPayment(orderId = orderA3.id, prevId = orderA3.id), + orderPayment(orderId = orderB1.id, prevId = orderB1.id), + orderPayment(orderId = orderB2.id, prevId = orderB2.id), + orderPayment(orderId = orderC1.id, prevId = orderC1.id), + ) + ) + + When("최초 주문 조회시 주문ID와 주문번호는 입력 하지 않아도") { + val request = OrderReadRequest( + lastViewedId = -1, + limit = 2, + lastViewedOrderNumber = INITIAL_READ_ORDER_NUMBER, + ) + val response = requestReadOrders(accessToken, request) + + Then("주문 내역이 조회된다") { + assertSoftly(response) { + statusCode shouldBe OK.value() + } + } + } + + When("마지막으로 조회된 주문의 ID와 주문 번호를 기준으로") { + val request = OrderReadRequest( + lastViewedId = orderC1.id, + limit = 2, + lastViewedOrderNumber = orderNumberC.value, + ) + + val response = requestReadOrders(accessToken, request) + + Then("주문 내역이 조회된다") { + assertSoftly(response) { + statusCode shouldBe OK.value() + } + } + } + + When("주문 내역이 존재하지 않는 경우") { + val neverOrderedMemberAccessToken = signInAsMember().accessToken + val request = OrderReadRequest( + lastViewedId = -1, + limit = 2, + lastViewedOrderNumber = INITIAL_READ_ORDER_NUMBER, + ) + + val response = requestReadOrders(neverOrderedMemberAccessToken, request) + + Then("빈 값을 반환한다") { + assertSoftly(response) { + statusCode shouldBe OK.value() + jsonPath().getList("orders").size shouldBe 0 + } + } + } + + When("주문 ID와 주문 번호가 다른 값으로 조회 경우") { + val request = OrderReadRequest( + lastViewedId = orderC1.id, + limit = 2, + lastViewedOrderNumber = orderNumberB.value, + ) + + val response = requestReadOrders(accessToken, request) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + assertSoftly(response) { + statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe NOT_INVALID_ORDER_READ_QUERY.errorMessage() + } + } + } + + When("최초 주문시 주문번호가 초기 플래그가 아닌 경우") { + val request = OrderReadRequest( + lastViewedId = -1, + limit = 2, + lastViewedOrderNumber = orderNumberB.value, + ) + + val response = requestReadOrders(accessToken, request) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + assertSoftly(response) { + statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe NOT_INVALID_ORDER_READ_QUERY.errorMessage() + } + } + } + + When("최초 주문시 주문ID가 초기 플래그가 아닌 경우") { + val request = OrderReadRequest( + lastViewedId = orderC1.id, + limit = 2, + lastViewedOrderNumber = INITIAL_READ_ORDER_NUMBER, + ) + + val response = requestReadOrders(accessToken, request) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + assertSoftly(response) { + statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe NOT_INVALID_ORDER_READ_QUERY.errorMessage() + } + } + } + } } }