From 2617f49930d02d43d6e8677ccebcd5593dfe6aaa Mon Sep 17 00:00:00 2001 From: Taeyeon <90550065+TaeyeonRoyce@users.noreply.github.com> Date: Wed, 31 Jan 2024 14:08:01 +0900 Subject: [PATCH] =?UTF-8?q?feature:=20=EB=B4=89=EB=8B=AC=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=83=81=ED=92=88=20=EC=B6=94=EA=B0=80=20api=20(#3?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: CartProduct 도메인 추가 * feat: Entity 존재 여부 확인 확장 함수 추가 * feat: CartProduct 저장 로직 추가 * refactor: ProductController 패키지 이동 * feat: CartProduct 저장 API 추가 * test: CartProduct 저장시 예외 상황 테스트 추가 * refactor: 중복된 dependency 제거 * chore: mockk 의존성 추가 * test: 로그인 셋업 메서드 추가 * test: 구현된 Auth 헤더 사용 하도록 CartProduct API 테스트 수정 * refactor: 중복된 dependency 제거 * refactor: Accessor -> LoginMember 네이밍 수정 * test: 검증부 내용 이름 수정 * feat: 회원 존재 유무 검증 로직 추가 * test: application 테스트내 빈 등록 범위 수정 * test: 회원 검증 추가에 따른 테스트 로직 수정 * feat: 상품 수량 값객체 추가 * feat: 봉달 상품 수량 값객체 적용 * test: 잘못된 봉달 상품 수량에 대한 테스트 추가 * refactor: 이미 봉달 목록에 존재하는 상품에 대한 처리 추가 * fix: value 필드 예약어 사용에 의한 오류 해결 --- build.gradle.kts | 26 ++-- .../application/cart/CartProductService.kt | 40 +++++ .../application/cart/dto/CartProductDtos.kt | 23 +++ .../com/petqua/common/domain/Repository.kt | 6 + .../kotlin/com/petqua/common/util/Validate.kt | 5 + .../auth/{Accessor.kt => LoginMember.kt} | 6 +- .../com/petqua/domain/cart/CartProduct.kt | 35 +++++ .../petqua/domain/cart/CartProductQuantity.kt | 21 +++ .../domain/cart/CartProductRepository.kt | 13 ++ .../com/petqua/domain/cart/DeliveryMethod.kt | 22 +++ .../exception/cart/CartProductException.kt | 13 ++ .../cart/CartProductExceptionType.kt | 25 +++ .../exception/member/MemberException.kt | 13 ++ .../exception/member/MemberExceptionType.kt | 22 +++ .../auth/LoginArgumentResolver.kt | 14 +- .../cart/CartProductController.kt | 34 ++++ .../presentation/cart/dto/CartProductDtos.kt | 22 +++ .../{ => product}/ProductController.kt | 2 +- .../cart/CartProductServiceTest.kt | 106 +++++++++++++ .../application/product/ProductServiceTest.kt | 3 +- .../domain/cart/CartProductQuantityTest.kt | 31 ++++ .../ProductCustomRepositoryImplTest.kt | 5 +- .../domain/{ => product}/ProductTest.kt | 2 +- .../cart/CartProductControllerTest.kt | 146 ++++++++++++++++++ .../kotlin/com/petqua/test/ApiTestConfig.kt | 27 ++++ .../com/petqua/test/fixture/MemberFixtures.kt | 18 +++ 26 files changed, 652 insertions(+), 28 deletions(-) create mode 100644 src/main/kotlin/com/petqua/application/cart/CartProductService.kt create mode 100644 src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt create mode 100644 src/main/kotlin/com/petqua/common/util/Validate.kt rename src/main/kotlin/com/petqua/domain/auth/{Accessor.kt => LoginMember.kt} (72%) create mode 100644 src/main/kotlin/com/petqua/domain/cart/CartProduct.kt create mode 100644 src/main/kotlin/com/petqua/domain/cart/CartProductQuantity.kt create mode 100644 src/main/kotlin/com/petqua/domain/cart/CartProductRepository.kt create mode 100644 src/main/kotlin/com/petqua/domain/cart/DeliveryMethod.kt create mode 100644 src/main/kotlin/com/petqua/exception/cart/CartProductException.kt create mode 100644 src/main/kotlin/com/petqua/exception/cart/CartProductExceptionType.kt create mode 100644 src/main/kotlin/com/petqua/exception/member/MemberException.kt create mode 100644 src/main/kotlin/com/petqua/exception/member/MemberExceptionType.kt create mode 100644 src/main/kotlin/com/petqua/presentation/cart/CartProductController.kt create mode 100644 src/main/kotlin/com/petqua/presentation/cart/dto/CartProductDtos.kt rename src/main/kotlin/com/petqua/presentation/{ => product}/ProductController.kt (96%) create mode 100644 src/test/kotlin/com/petqua/application/cart/CartProductServiceTest.kt create mode 100644 src/test/kotlin/com/petqua/domain/cart/CartProductQuantityTest.kt rename src/test/kotlin/com/petqua/domain/{ => product}/ProductCustomRepositoryImplTest.kt (98%) rename src/test/kotlin/com/petqua/domain/{ => product}/ProductTest.kt (94%) create mode 100644 src/test/kotlin/com/petqua/presentation/cart/CartProductControllerTest.kt create mode 100644 src/test/kotlin/com/petqua/test/fixture/MemberFixtures.kt diff --git a/build.gradle.kts b/build.gradle.kts index e498957a..dbd6c011 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,34 +20,36 @@ repositories { } dependencies { + // kotlin + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // spring data jpa implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + // spring boot web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-webflux") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlin:kotlin-reflect") + + // jwt implementation("io.jsonwebtoken:jjwt-api:0.11.5") implementation("io.jsonwebtoken:jjwt-impl:0.11.5") implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") - annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") - - // kotlin jdsl - implementation("com.linecorp.kotlin-jdsl:jpql-dsl:3.3.0") - implementation("com.linecorp.kotlin-jdsl:jpql-render:3.3.0") - - implementation("org.springframework.boot:spring-boot-starter-cache") - implementation("io.jsonwebtoken:jjwt-api:0.11.5") + // annotation processor + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") // kotlin jdsl implementation("com.linecorp.kotlin-jdsl:jpql-dsl:3.3.0") implementation("com.linecorp.kotlin-jdsl:jpql-render:3.3.0") + // spring boot cache implementation("org.springframework.boot:spring-boot-starter-cache") - + runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.h2database:h2") - annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + testImplementation("io.mockk:mockk:1.13.9") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.kotest:kotest-runner-junit5:5.4.2") testImplementation("io.kotest:kotest-assertions-core:5.4.2") diff --git a/src/main/kotlin/com/petqua/application/cart/CartProductService.kt b/src/main/kotlin/com/petqua/application/cart/CartProductService.kt new file mode 100644 index 00000000..6e9433be --- /dev/null +++ b/src/main/kotlin/com/petqua/application/cart/CartProductService.kt @@ -0,0 +1,40 @@ +package com.petqua.application.cart + +import com.petqua.application.cart.dto.SaveCartProductCommand +import com.petqua.common.domain.existByIdOrThrow +import com.petqua.domain.cart.CartProductRepository +import com.petqua.domain.member.MemberRepository +import com.petqua.domain.product.ProductRepository +import com.petqua.exception.cart.CartProductException +import com.petqua.exception.cart.CartProductExceptionType.DUPLICATED_PRODUCT +import com.petqua.exception.member.MemberException +import com.petqua.exception.member.MemberExceptionType.NOT_FOUND_MEMBER +import com.petqua.exception.product.ProductException +import com.petqua.exception.product.ProductExceptionType.NOT_FOUND_PRODUCT +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional +@Service +class CartProductService( + private val cartProductRepository: CartProductRepository, + private val productRepository: ProductRepository, + private val memberRepository: MemberRepository, +) { + + fun save(command: SaveCartProductCommand): Long { + memberRepository.existByIdOrThrow(command.memberId, MemberException(NOT_FOUND_MEMBER)) + productRepository.existByIdOrThrow(command.productId, ProductException(NOT_FOUND_PRODUCT)) + validateDuplicatedProduct(command) + return cartProductRepository.save(command.toCartProduct()).id + } + + private fun validateDuplicatedProduct(command: SaveCartProductCommand) { + cartProductRepository.findByMemberIdAndProductIdAndIsMaleAndDeliveryMethod( + memberId = command.memberId, + productId = command.productId, + isMale = command.isMale, + deliveryMethod = command.deliveryMethod + )?.also { throw CartProductException(DUPLICATED_PRODUCT) } + } +} diff --git a/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt b/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt new file mode 100644 index 00000000..12f71e4e --- /dev/null +++ b/src/main/kotlin/com/petqua/application/cart/dto/CartProductDtos.kt @@ -0,0 +1,23 @@ +package com.petqua.application.cart.dto + +import com.petqua.domain.cart.CartProduct +import com.petqua.domain.cart.CartProductQuantity +import com.petqua.domain.cart.DeliveryMethod + +data class SaveCartProductCommand( + val memberId: Long, + val productId: Long, + val quantity: Int, + val isMale: Boolean, + val deliveryMethod: DeliveryMethod, +) { + fun toCartProduct(): CartProduct { + return CartProduct( + memberId = memberId, + productId = productId, + quantity = CartProductQuantity(quantity), + isMale = isMale, + deliveryMethod = deliveryMethod, + ) + } +} diff --git a/src/main/kotlin/com/petqua/common/domain/Repository.kt b/src/main/kotlin/com/petqua/common/domain/Repository.kt index be73ec19..64c5b9e1 100644 --- a/src/main/kotlin/com/petqua/common/domain/Repository.kt +++ b/src/main/kotlin/com/petqua/common/domain/Repository.kt @@ -6,3 +6,9 @@ import org.springframework.data.repository.findByIdOrNull inline fun CrudRepository.findByIdOrThrow( id: ID, e: Exception = IllegalArgumentException("${T::class.java.name} entity 를 찾을 수 없습니다. id=$id") ): T = findByIdOrNull(id) ?: throw e + + +inline fun CrudRepository.existByIdOrThrow( + id: ID, + e: Exception = IllegalArgumentException("${T::class.java.name} entity 를 찾을 수 없습니다. id=$id") +): Unit = if (!existsById(id!!)) throw e else Unit diff --git a/src/main/kotlin/com/petqua/common/util/Validate.kt b/src/main/kotlin/com/petqua/common/util/Validate.kt new file mode 100644 index 00000000..be265dca --- /dev/null +++ b/src/main/kotlin/com/petqua/common/util/Validate.kt @@ -0,0 +1,5 @@ +package com.petqua.common.util + +inline fun throwExceptionWhen(condition: Boolean, exceptionSupplier: () -> RuntimeException) { + if (condition) throw exceptionSupplier() +} diff --git a/src/main/kotlin/com/petqua/domain/auth/Accessor.kt b/src/main/kotlin/com/petqua/domain/auth/LoginMember.kt similarity index 72% rename from src/main/kotlin/com/petqua/domain/auth/Accessor.kt rename to src/main/kotlin/com/petqua/domain/auth/LoginMember.kt index ed523c56..544610e8 100644 --- a/src/main/kotlin/com/petqua/domain/auth/Accessor.kt +++ b/src/main/kotlin/com/petqua/domain/auth/LoginMember.kt @@ -2,14 +2,14 @@ package com.petqua.domain.auth import com.petqua.domain.auth.token.AccessTokenClaims -class Accessor( +class LoginMember( val memberId: Long, val authority: Authority, ) { companion object { - fun from(accessTokenClaims: AccessTokenClaims): Accessor { - return Accessor( + fun from(accessTokenClaims: AccessTokenClaims): LoginMember { + return LoginMember( memberId = accessTokenClaims.memberId, authority = accessTokenClaims.authority ) diff --git a/src/main/kotlin/com/petqua/domain/cart/CartProduct.kt b/src/main/kotlin/com/petqua/domain/cart/CartProduct.kt new file mode 100644 index 00000000..d596e270 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/cart/CartProduct.kt @@ -0,0 +1,35 @@ +package com.petqua.domain.cart + +import com.petqua.common.domain.BaseEntity +import jakarta.persistence.AttributeOverride +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class CartProduct( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(nullable = false) + val memberId: Long, + + @Column(nullable = false) + val productId: Long, + + @Embedded + @AttributeOverride(name = "value", column = Column(name = "quantity", nullable = false)) + val quantity: CartProductQuantity, + + @Column(nullable = false) + val isMale: Boolean, + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + val deliveryMethod: DeliveryMethod, +) : BaseEntity() diff --git a/src/main/kotlin/com/petqua/domain/cart/CartProductQuantity.kt b/src/main/kotlin/com/petqua/domain/cart/CartProductQuantity.kt new file mode 100644 index 00000000..a09e8f4c --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/cart/CartProductQuantity.kt @@ -0,0 +1,21 @@ +package com.petqua.domain.cart + +import com.petqua.common.util.throwExceptionWhen +import com.petqua.exception.cart.CartProductException +import com.petqua.exception.cart.CartProductExceptionType.PRODUCT_QUANTITY_OVER_MAXIMUM +import com.petqua.exception.cart.CartProductExceptionType.PRODUCT_QUANTITY_UNDER_MINIMUM +import jakarta.persistence.Embeddable + +private const val MIN_QUANTITY = 1 +private const val MAX_QUANTITY = 99 + +@Embeddable +class CartProductQuantity( + val value: Int = 1, +) { + + init { + throwExceptionWhen(value < MIN_QUANTITY) { CartProductException(PRODUCT_QUANTITY_UNDER_MINIMUM) } + throwExceptionWhen(value > MAX_QUANTITY) { CartProductException(PRODUCT_QUANTITY_OVER_MAXIMUM) } + } +} diff --git a/src/main/kotlin/com/petqua/domain/cart/CartProductRepository.kt b/src/main/kotlin/com/petqua/domain/cart/CartProductRepository.kt new file mode 100644 index 00000000..3e31c0de --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/cart/CartProductRepository.kt @@ -0,0 +1,13 @@ +package com.petqua.domain.cart + +import org.springframework.data.jpa.repository.JpaRepository + +interface CartProductRepository : JpaRepository { + + fun findByMemberIdAndProductIdAndIsMaleAndDeliveryMethod( + memberId: Long, + productId: Long, + isMale: Boolean, + deliveryMethod: DeliveryMethod + ): CartProduct? +} diff --git a/src/main/kotlin/com/petqua/domain/cart/DeliveryMethod.kt b/src/main/kotlin/com/petqua/domain/cart/DeliveryMethod.kt new file mode 100644 index 00000000..0bfb1ef7 --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/cart/DeliveryMethod.kt @@ -0,0 +1,22 @@ +package com.petqua.domain.cart + +import com.petqua.exception.cart.CartProductException +import com.petqua.exception.cart.CartProductExceptionType.INVALID_DELIVERY_METHOD +import java.util.Locale.ENGLISH + +enum class DeliveryMethod( + val description: String, +) { + + COMMON("일반 운송"), + SAFETY("안전 운송"), + PICK_UP("직접 방문"), + ; + + companion object { + fun from(name: String): DeliveryMethod { + return enumValues().find { it.name == name.uppercase(ENGLISH) } + ?: throw CartProductException(INVALID_DELIVERY_METHOD) + } + } +} diff --git a/src/main/kotlin/com/petqua/exception/cart/CartProductException.kt b/src/main/kotlin/com/petqua/exception/cart/CartProductException.kt new file mode 100644 index 00000000..aa8ba83e --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/cart/CartProductException.kt @@ -0,0 +1,13 @@ +package com.petqua.exception.cart + +import com.petqua.common.exception.BaseException +import com.petqua.common.exception.BaseExceptionType + +class CartProductException( + private val exceptionType: CartProductExceptionType, +) : BaseException() { + + override fun exceptionType(): BaseExceptionType { + return exceptionType + } +} diff --git a/src/main/kotlin/com/petqua/exception/cart/CartProductExceptionType.kt b/src/main/kotlin/com/petqua/exception/cart/CartProductExceptionType.kt new file mode 100644 index 00000000..5e80b45a --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/cart/CartProductExceptionType.kt @@ -0,0 +1,25 @@ +package com.petqua.exception.cart + +import com.petqua.common.exception.BaseExceptionType +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST + +enum class CartProductExceptionType( + private val httpStatus: HttpStatus, + private val errorMessage: String, +) : BaseExceptionType { + + INVALID_DELIVERY_METHOD(httpStatus = BAD_REQUEST, errorMessage = "유효하지 않는 배송 방법입니다."), + PRODUCT_QUANTITY_UNDER_MINIMUM(httpStatus = BAD_REQUEST, errorMessage = "최소 1개 이상의 상품을 담을 수 있습니다."), + PRODUCT_QUANTITY_OVER_MAXIMUM(httpStatus = BAD_REQUEST, errorMessage = "최대 99개까지 구매할 수 있습니다."), + DUPLICATED_PRODUCT(httpStatus = BAD_REQUEST, errorMessage = "이미 장바구니에 담긴 상품입니다.") + ; + + override fun httpStatus(): HttpStatus { + return httpStatus + } + + override fun errorMessage(): String { + return errorMessage + } +} diff --git a/src/main/kotlin/com/petqua/exception/member/MemberException.kt b/src/main/kotlin/com/petqua/exception/member/MemberException.kt new file mode 100644 index 00000000..dcf7395b --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/member/MemberException.kt @@ -0,0 +1,13 @@ +package com.petqua.exception.member + +import com.petqua.common.exception.BaseException +import com.petqua.common.exception.BaseExceptionType + +class MemberException( + private val exceptionType: MemberExceptionType, +) : BaseException() { + + override fun exceptionType(): BaseExceptionType { + return exceptionType + } +} diff --git a/src/main/kotlin/com/petqua/exception/member/MemberExceptionType.kt b/src/main/kotlin/com/petqua/exception/member/MemberExceptionType.kt new file mode 100644 index 00000000..79a35f1d --- /dev/null +++ b/src/main/kotlin/com/petqua/exception/member/MemberExceptionType.kt @@ -0,0 +1,22 @@ +package com.petqua.exception.member + +import com.petqua.common.exception.BaseExceptionType +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.NOT_FOUND + +enum class MemberExceptionType( + private val httpStatus: HttpStatus, + private val errorMessage: String, +) : BaseExceptionType { + + NOT_FOUND_MEMBER(NOT_FOUND, "존재하지 않는 회원입니다."), + ; + + override fun httpStatus(): HttpStatus { + return httpStatus + } + + override fun errorMessage(): String { + return errorMessage + } +} diff --git a/src/main/kotlin/com/petqua/presentation/auth/LoginArgumentResolver.kt b/src/main/kotlin/com/petqua/presentation/auth/LoginArgumentResolver.kt index a75e0aa4..6e927c24 100644 --- a/src/main/kotlin/com/petqua/presentation/auth/LoginArgumentResolver.kt +++ b/src/main/kotlin/com/petqua/presentation/auth/LoginArgumentResolver.kt @@ -2,8 +2,8 @@ package com.petqua.presentation.auth import com.petqua.common.exception.auth.AuthException import com.petqua.common.exception.auth.AuthExceptionType -import com.petqua.domain.auth.Accessor import com.petqua.domain.auth.Auth +import com.petqua.domain.auth.LoginMember import com.petqua.domain.auth.token.AuthTokenProvider import com.petqua.domain.auth.token.RefreshTokenRepository import jakarta.servlet.http.HttpServletRequest @@ -25,7 +25,7 @@ class LoginArgumentResolver( override fun supportsParameter(parameter: MethodParameter): Boolean { return parameter.hasParameterAnnotation(Auth::class.java) - && parameter.getParameterType() == Accessor::class.java + && parameter.getParameterType() == LoginMember::class.java } override fun resolveArgument( @@ -33,21 +33,21 @@ class LoginArgumentResolver( mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory? - ): Accessor { + ): LoginMember { val request = webRequest.getNativeRequest(HttpServletRequest::class.java) ?: throw AuthException(AuthExceptionType.INVALID_REQUEST) - val refreshToken = request.cookies?.find {it.name == REFRESH_TOKEN_COOKIE}?.value + val refreshToken = request.cookies?.find { it.name == REFRESH_TOKEN_COOKIE }?.value val accessToken = webRequest.getHeader(HttpHeaders.AUTHORIZATION) as String val accessTokenClaims = authTokenProvider.getAccessTokenClaims(accessToken) if (refreshToken == null) { - return Accessor.from(accessTokenClaims) + return LoginMember.from(accessTokenClaims) } val savedRefreshToken = refreshTokenRepository.findByMemberId(accessTokenClaims.memberId) ?: throw AuthException(AuthExceptionType.INVALID_REFRESH_TOKEN) if (savedRefreshToken.token == refreshToken) { - return Accessor.from(accessTokenClaims) + return LoginMember.from(accessTokenClaims) } throw AuthException(AuthExceptionType.INVALID_REFRESH_TOKEN) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/petqua/presentation/cart/CartProductController.kt b/src/main/kotlin/com/petqua/presentation/cart/CartProductController.kt new file mode 100644 index 00000000..881dd576 --- /dev/null +++ b/src/main/kotlin/com/petqua/presentation/cart/CartProductController.kt @@ -0,0 +1,34 @@ +package com.petqua.presentation.cart + +import com.petqua.application.cart.CartProductService +import com.petqua.application.product.dto.ProductDetailResponse +import com.petqua.domain.auth.Auth +import com.petqua.domain.auth.LoginMember +import com.petqua.presentation.cart.dto.SaveCartProductRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +@RequestMapping("/carts") +@RestController +class CartProductController( + private val cartProductService: CartProductService, +) { + + @PostMapping + fun save( + @Auth loginMember: LoginMember, + @RequestBody request: SaveCartProductRequest + ): ResponseEntity { + val command = request.toCommand(loginMember.memberId) + val cartProductId = cartProductService.save(command) + val location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/items/{id}") + .buildAndExpand(cartProductId) + .toUri() + return ResponseEntity.created(location).build() + } +} diff --git a/src/main/kotlin/com/petqua/presentation/cart/dto/CartProductDtos.kt b/src/main/kotlin/com/petqua/presentation/cart/dto/CartProductDtos.kt new file mode 100644 index 00000000..a4ef4bb0 --- /dev/null +++ b/src/main/kotlin/com/petqua/presentation/cart/dto/CartProductDtos.kt @@ -0,0 +1,22 @@ +package com.petqua.presentation.cart.dto + +import com.petqua.application.cart.dto.SaveCartProductCommand +import com.petqua.domain.cart.DeliveryMethod + +data class SaveCartProductRequest( + val productId: Long, + val quantity: Int, + val isMale: Boolean, + val deliveryMethod: String, +) { + + fun toCommand(memberId: Long): SaveCartProductCommand { + return SaveCartProductCommand( + memberId = memberId, + productId = productId, + quantity = quantity, + isMale = isMale, + deliveryMethod = DeliveryMethod.from(deliveryMethod) + ) + } +} diff --git a/src/main/kotlin/com/petqua/presentation/ProductController.kt b/src/main/kotlin/com/petqua/presentation/product/ProductController.kt similarity index 96% rename from src/main/kotlin/com/petqua/presentation/ProductController.kt rename to src/main/kotlin/com/petqua/presentation/product/ProductController.kt index 5d7f11f0..2c8aedf7 100644 --- a/src/main/kotlin/com/petqua/presentation/ProductController.kt +++ b/src/main/kotlin/com/petqua/presentation/product/ProductController.kt @@ -1,4 +1,4 @@ -package com.petqua.presentation +package com.petqua.presentation.product import com.petqua.application.product.ProductService import com.petqua.application.product.dto.ProductDetailResponse diff --git a/src/test/kotlin/com/petqua/application/cart/CartProductServiceTest.kt b/src/test/kotlin/com/petqua/application/cart/CartProductServiceTest.kt new file mode 100644 index 00000000..006c039d --- /dev/null +++ b/src/test/kotlin/com/petqua/application/cart/CartProductServiceTest.kt @@ -0,0 +1,106 @@ +package com.petqua.application.cart + +import com.petqua.application.cart.dto.SaveCartProductCommand +import com.petqua.domain.cart.CartProductRepository +import com.petqua.domain.cart.DeliveryMethod +import com.petqua.domain.member.MemberRepository +import com.petqua.domain.product.ProductRepository +import com.petqua.exception.cart.CartProductException +import com.petqua.exception.cart.CartProductExceptionType.DUPLICATED_PRODUCT +import com.petqua.exception.member.MemberException +import com.petqua.exception.member.MemberExceptionType.NOT_FOUND_MEMBER +import com.petqua.exception.product.ProductException +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.product +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 + +@SpringBootTest(webEnvironment = NONE) +class CartProductServiceTest( + private val cartProductService: CartProductService, + private val cartProductRepository: CartProductRepository, + private val productRepository: ProductRepository, + private val memberRepository: MemberRepository, + private val dataCleaner: DataCleaner, +) : BehaviorSpec({ + + Given("봉달 상품 저장 명령으로") { + val productId = productRepository.save(product(id = 1L)).id + val memberId = memberRepository.save(member(id = 1L)).id + val command = SaveCartProductCommand( + memberId = memberId, + productId = productId, + quantity = 1, + isMale = true, + deliveryMethod = DeliveryMethod.COMMON, + ) + + When("봉달 상품을") { + cartProductService.save(command) + + Then("저장할 수 있다") { + cartProductRepository.findAll().size shouldBe 1 + } + } + } + + Given("봉달 상품 저장시") { + val productId = productRepository.save(product(id = 1L)).id + val memberId = memberRepository.save(member(id = 1L)).id + + When("존재 하지 않는 회원이 요청 하는 경우") { + val command = SaveCartProductCommand( + memberId = 999L, + productId = productId, + quantity = 1, + isMale = true, + deliveryMethod = DeliveryMethod.COMMON, + ) + Then("예외가 발생 한다") { + shouldThrow { + cartProductService.save(command) + }.exceptionType() shouldBe NOT_FOUND_MEMBER + } + } + + When("존재 하지 않는 상품이 요청 하는 경우") { + val command = SaveCartProductCommand( + memberId = memberId, + productId = 999L, + quantity = 1, + isMale = true, + deliveryMethod = DeliveryMethod.COMMON, + ) + Then("예외가 발생 한다") { + shouldThrow { + cartProductService.save(command) + }.exceptionType() shouldBe NOT_FOUND_PRODUCT + } + } + + When("중복 상품이 요청 하는 경우") { + val command = SaveCartProductCommand( + memberId = memberId, + productId = productId, + quantity = 1, + isMale = true, + deliveryMethod = DeliveryMethod.COMMON, + ) + cartProductService.save(command) + Then("예외가 발생 한다") { + shouldThrow { + cartProductService.save(command) + }.exceptionType() shouldBe DUPLICATED_PRODUCT + } + } + } + + afterContainer { + dataCleaner.clean() + } +}) diff --git a/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt b/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt index ccfeb9d2..f6c01960 100644 --- a/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt +++ b/src/test/kotlin/com/petqua/application/product/ProductServiceTest.kt @@ -14,8 +14,9 @@ import com.petqua.test.fixture.store 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 -@SpringBootTest +@SpringBootTest(webEnvironment = WebEnvironment.NONE) class ProductServiceTest( private val productService: ProductService, private val productRepository: ProductRepository, diff --git a/src/test/kotlin/com/petqua/domain/cart/CartProductQuantityTest.kt b/src/test/kotlin/com/petqua/domain/cart/CartProductQuantityTest.kt new file mode 100644 index 00000000..fec7ab1f --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/cart/CartProductQuantityTest.kt @@ -0,0 +1,31 @@ +package com.petqua.domain.cart + +import com.petqua.exception.cart.CartProductException +import com.petqua.exception.cart.CartProductExceptionType.PRODUCT_QUANTITY_OVER_MAXIMUM +import com.petqua.exception.cart.CartProductExceptionType.PRODUCT_QUANTITY_UNDER_MINIMUM +import io.kotest.core.spec.style.StringSpec +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy + +class CartProductQuantityTest : StringSpec({ + "상품 수량 값객체 생성" { + val quantity = CartProductQuantity(5) + assertThat(quantity.value).isEqualTo(5) + } + + "최소 수량 미만인 경우 생성 실패" { + assertThatThrownBy { + CartProductQuantity(0) + }.isInstanceOf(CartProductException::class.java) + .extracting("exceptionType") + .isEqualTo(PRODUCT_QUANTITY_UNDER_MINIMUM) + } + + "최대 수량 초과인 경우 생성 실패" { + assertThatThrownBy { + CartProductQuantity(100) + }.isInstanceOf(CartProductException::class.java) + .extracting("exceptionType") + .isEqualTo(PRODUCT_QUANTITY_OVER_MAXIMUM) + } +}) diff --git a/src/test/kotlin/com/petqua/domain/ProductCustomRepositoryImplTest.kt b/src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt similarity index 98% rename from src/test/kotlin/com/petqua/domain/ProductCustomRepositoryImplTest.kt rename to src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt index 7cafd5f8..8fdf3ae7 100644 --- a/src/test/kotlin/com/petqua/domain/ProductCustomRepositoryImplTest.kt +++ b/src/test/kotlin/com/petqua/domain/product/ProductCustomRepositoryImplTest.kt @@ -1,6 +1,5 @@ -package com.petqua.domain +package com.petqua.domain.product -import com.petqua.domain.product.ProductRepository import com.petqua.domain.product.ProductSourceType.HOME_RECOMMENDED import com.petqua.domain.product.Sorter.ENROLLMENT_DATE_DESC import com.petqua.domain.product.Sorter.REVIEW_COUNT_DESC @@ -19,10 +18,10 @@ 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 org.springframework.boot.test.context.SpringBootTest @SpringBootTest class ProductCustomRepositoryImplTest( diff --git a/src/test/kotlin/com/petqua/domain/ProductTest.kt b/src/test/kotlin/com/petqua/domain/product/ProductTest.kt similarity index 94% rename from src/test/kotlin/com/petqua/domain/ProductTest.kt rename to src/test/kotlin/com/petqua/domain/product/ProductTest.kt index ec0b41bc..76cd9528 100644 --- a/src/test/kotlin/com/petqua/domain/ProductTest.kt +++ b/src/test/kotlin/com/petqua/domain/product/ProductTest.kt @@ -1,4 +1,4 @@ -package com.petqua.domain +package com.petqua.domain.product import com.petqua.test.fixture.product import io.kotest.core.spec.style.BehaviorSpec diff --git a/src/test/kotlin/com/petqua/presentation/cart/CartProductControllerTest.kt b/src/test/kotlin/com/petqua/presentation/cart/CartProductControllerTest.kt new file mode 100644 index 00000000..95a6dbb9 --- /dev/null +++ b/src/test/kotlin/com/petqua/presentation/cart/CartProductControllerTest.kt @@ -0,0 +1,146 @@ +package com.petqua.presentation.cart + +import com.petqua.common.exception.ExceptionResponse +import com.petqua.domain.product.ProductRepository +import com.petqua.exception.cart.CartProductExceptionType.INVALID_DELIVERY_METHOD +import com.petqua.exception.cart.CartProductExceptionType.PRODUCT_QUANTITY_OVER_MAXIMUM +import com.petqua.exception.product.ProductExceptionType.NOT_FOUND_PRODUCT +import com.petqua.presentation.cart.dto.SaveCartProductRequest +import com.petqua.test.ApiTestConfig +import com.petqua.test.fixture.product +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 org.assertj.core.api.SoftAssertions.assertSoftly +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.HttpStatus + +class CartProductControllerTest( + private val productRepository: ProductRepository +) : ApiTestConfig() { + init { + val memberAuthResponse = signInAsMember() + val savedProduct = productRepository.save(product(id = 1L)) + Given("봉달에 상품 저장을") { + val request = SaveCartProductRequest( + productId = savedProduct.id, + quantity = 1, + isMale = true, + deliveryMethod = "SAFETY" + ) + When("요청 하면") { + val response = Given { + log().all() + .body(request) + .header(AUTHORIZATION, memberAuthResponse.accessToken) + .contentType("application/json") + } When { + post("/carts") + } Then { + log().all() + } Extract { + response() + } + + Then("봉달 목록에 상품이 저장된다") { + assertSoftly { + it.assertThat(response.statusCode).isEqualTo(HttpStatus.CREATED.value()) + it.assertThat(response.header(HttpHeaders.LOCATION)).contains("/carts/items") + } + } + } + } + + Given("봉달에 상품 저장 요청시") { + When("지원하지 않는 배송 방식으로 요청 하면") { + val request = SaveCartProductRequest( + productId = savedProduct.id, + quantity = 1, + isMale = true, + deliveryMethod = "NOT_SUPPORTED" + ) + val response = Given { + log().all() + .body(request) + .header(AUTHORIZATION, memberAuthResponse.accessToken) + .contentType("application/json") + } When { + post("/carts") + } Then { + log().all() + } Extract { + response() + } + + Then("예외가 발생한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + assertSoftly { + it.assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST.value()) + it.assertThat(errorResponse.message).isEqualTo(INVALID_DELIVERY_METHOD.errorMessage()) + } + } + } + + When("존재 하지 않는 상품 저장을 요청 하면") { + val request = SaveCartProductRequest( + productId = 999L, + quantity = 1, + isMale = true, + deliveryMethod = "SAFETY" + ) + val response = Given { + log().all() + .body(request) + .header(AUTHORIZATION, memberAuthResponse.accessToken) + .contentType("application/json") + } When { + post("/carts") + } Then { + log().all() + } Extract { + response() + } + + Then("예외가 발생한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + assertSoftly { + it.assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND.value()) + it.assertThat(errorResponse.message).isEqualTo(NOT_FOUND_PRODUCT.errorMessage()) + } + } + } + + When("유효하지 않은 상품 수량을 담으면") { + val request = SaveCartProductRequest( + productId = savedProduct.id, + quantity = 1_000, + isMale = false, + deliveryMethod = "SAFETY" + ) + val response = Given { + log().all() + .body(request) + .header(AUTHORIZATION, memberAuthResponse.accessToken) + .contentType("application/json") + } When { + post("/carts") + } Then { + log().all() + } Extract { + response() + } + + Then("예외가 발생한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + assertSoftly { + it.assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST.value()) + it.assertThat(errorResponse.message).isEqualTo(PRODUCT_QUANTITY_OVER_MAXIMUM.errorMessage()) + } + } + } +// TODO When("중복 상품을 담으면") { + } + } +} diff --git a/src/test/kotlin/com/petqua/test/ApiTestConfig.kt b/src/test/kotlin/com/petqua/test/ApiTestConfig.kt index da7538c7..069be5c5 100644 --- a/src/test/kotlin/com/petqua/test/ApiTestConfig.kt +++ b/src/test/kotlin/com/petqua/test/ApiTestConfig.kt @@ -1,12 +1,21 @@ package com.petqua.test +import com.petqua.application.auth.AuthResponse +import com.petqua.test.config.OauthTestConfig import io.kotest.core.spec.style.BehaviorSpec import io.restassured.RestAssured +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.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.annotation.Import +@Import(OauthTestConfig::class) @SpringBootTest(webEnvironment = RANDOM_PORT) abstract class ApiTestConfig : BehaviorSpec() { @@ -25,4 +34,22 @@ abstract class ApiTestConfig : BehaviorSpec() { dataCleaner.clean() } } + + final fun signInAsMember(): AuthResponse { + val response = requestSignIn() + return response.`as`(AuthResponse::class.java) + } + + private fun requestSignIn(): Response { + return Given { + log().all() + .queryParam("code", "code") + } When { + get("/oauth/login/{oauthServerType}", "kakao") + } Then { + log().all() + } Extract { + response() + } + } } diff --git a/src/test/kotlin/com/petqua/test/fixture/MemberFixtures.kt b/src/test/kotlin/com/petqua/test/fixture/MemberFixtures.kt new file mode 100644 index 00000000..bc0d1832 --- /dev/null +++ b/src/test/kotlin/com/petqua/test/fixture/MemberFixtures.kt @@ -0,0 +1,18 @@ +package com.petqua.test.fixture + +import com.petqua.domain.auth.Authority +import com.petqua.domain.member.Member + +fun member( + id: Long = 1L, + oauthId: String = "oauthId", + oauthServerNumber: Int = 1, + authority: Authority = Authority.MEMBER +): Member { + return Member( + id, + oauthId, + oauthServerNumber, + authority + ) +}