From a856852a6999a839681b22a05fdfcdc26ded7736 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Thu, 11 Jul 2024 00:22:18 +0900 Subject: [PATCH 01/29] =?UTF-8?q?docs(reamde):=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8142135..9f8ce46 100644 --- a/README.md +++ b/README.md @@ -1 +1,27 @@ -# spring-shopping-product \ No newline at end of file +# spring-shopping-product +## 기능요구사항 +### 상품 +- [ ] 상품 등록 api + - [ ] 공백 포함 15자 검사 + - [ ] 특정 특수문자 의외의 특수문자 존재하지 않는지 검사 + - [ ] 비속어 검사 +- [ ] 상품 조회 api +- [ ] 상품 수정 api + - [ ] 상품명 형식 검사 +- [ ] 상품 삭제 api +### 로그인 +- [ ] 회원가입 + - [ ] 이메일 중복 검사 + - [ ] 이메일 형식 검사 + - [ ] 비밀번호 암호화 + - [ ] 비밀번호 형식 검사 +- [ ] 로그인 + - [ ] 로그인시 토큰 발급 api(엑세스, 리프레시 토큰) + - [ ] 엑세스 토큰 재발급 api +### 위시리스트 +- [ ] 위시리스트 상품목록 조회 api +- [ ] 위시리스트 상품 추가 api + - [ ] 중복 추가 방지 + - [ ] 상품 존재 여부 검사 +- [ ] 위시리스트 상품 삭제 api + - [ ] 상품 존재 여부 검사 From e61b311d01c59dd8a7159b9e1a8e5b019f76d131 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Mon, 15 Jul 2024 00:59:56 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat(common):=20api=20=ED=86=B5=EC=8B=A0?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EA=B3=B5=EC=9A=A9=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/shopping/.gitkeep | 0 .../kotlin/shopping/common/api/ApiResponse.kt | 16 +++++ .../shopping/common/api/GlobalErrorHandler.kt | 68 +++++++++++++++++++ .../shopping/common/error/ApiException.kt | 7 ++ .../kotlin/shopping/common/error/ErrorCode.kt | 10 +++ .../shopping/common/error/ErrorMessage.kt | 7 ++ 6 files changed, 108 insertions(+) delete mode 100644 src/main/kotlin/shopping/.gitkeep create mode 100644 src/main/kotlin/shopping/common/api/ApiResponse.kt create mode 100644 src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt create mode 100644 src/main/kotlin/shopping/common/error/ApiException.kt create mode 100644 src/main/kotlin/shopping/common/error/ErrorCode.kt create mode 100644 src/main/kotlin/shopping/common/error/ErrorMessage.kt diff --git a/src/main/kotlin/shopping/.gitkeep b/src/main/kotlin/shopping/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/shopping/common/api/ApiResponse.kt b/src/main/kotlin/shopping/common/api/ApiResponse.kt new file mode 100644 index 0000000..b4b28ad --- /dev/null +++ b/src/main/kotlin/shopping/common/api/ApiResponse.kt @@ -0,0 +1,16 @@ +package shopping.common.api + +import shopping.common.error.ErrorMessage + +class ApiResponse private constructor( + val data: T? = null, + val error: ErrorMessage? = null, +) { + companion object { + fun success(): ApiResponse = ApiResponse() + + fun success(data: T): ApiResponse = ApiResponse(data = data) + + fun error(errorMessage: ErrorMessage?): ApiResponse = ApiResponse(error = errorMessage) + } +} diff --git a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt new file mode 100644 index 0000000..fa2e20a --- /dev/null +++ b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt @@ -0,0 +1,68 @@ +package shopping.common.api + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.validation.FieldError +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode +import shopping.common.error.ErrorMessage + +@RestControllerAdvice +class GlobalErrorHandler { + private val logger: Logger = LoggerFactory.getLogger(GlobalErrorHandler::class.java) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun requestValidationExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity> { + val errorMessage = + ErrorMessage( + code = ErrorCode.VALIDATION_ERROR, + message = e.message, + data = e.bindingResult.fieldErrors.map { it.toData() }, + ) + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(errorMessage)) + } + + @ExceptionHandler(ApiException::class) + fun apiExceptionHandler(e: ApiException): ResponseEntity> { + logger.warn("ApiException: {}", e.message, e) + val errorMessage = + ErrorMessage( + code = e.errorCode, + message = e.message, + data = e.data, + ) + + return ResponseEntity + .ok() + .body(ApiResponse.error(errorMessage)) + } + + @ExceptionHandler(Exception::class) + fun exceptionHandler(e: Exception): ResponseEntity> { + logger.error("Exception: {}", e.message, e) + val errorMessage = + ErrorMessage( + code = ErrorCode.UNKNOWN_ERROR, + message = e.message ?: "Unknown Error", + ) + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(errorMessage)) + } +} + +private fun FieldError.toData() = + mapOf( + "field" to field, + "rejectedValue" to rejectedValue, + "message" to defaultMessage, + ) diff --git a/src/main/kotlin/shopping/common/error/ApiException.kt b/src/main/kotlin/shopping/common/error/ApiException.kt new file mode 100644 index 0000000..5889416 --- /dev/null +++ b/src/main/kotlin/shopping/common/error/ApiException.kt @@ -0,0 +1,7 @@ +package shopping.common.error + +open class ApiException( + val errorCode: ErrorCode, + override val message: String, + val data: Any? = null, +) : RuntimeException() diff --git a/src/main/kotlin/shopping/common/error/ErrorCode.kt b/src/main/kotlin/shopping/common/error/ErrorCode.kt new file mode 100644 index 0000000..c111818 --- /dev/null +++ b/src/main/kotlin/shopping/common/error/ErrorCode.kt @@ -0,0 +1,10 @@ +package shopping.common.error + +enum class ErrorCode( + val code: String, +) { + VALIDATION_ERROR("C0001"), + UNKNOWN_ERROR("C0002"), + + PRODUCT_NAME_CONTAIN_BAD_WORD("P0001"), +} diff --git a/src/main/kotlin/shopping/common/error/ErrorMessage.kt b/src/main/kotlin/shopping/common/error/ErrorMessage.kt new file mode 100644 index 0000000..be654e4 --- /dev/null +++ b/src/main/kotlin/shopping/common/error/ErrorMessage.kt @@ -0,0 +1,7 @@ +package shopping.common.error + +data class ErrorMessage( + val code: ErrorCode, + val message: String, + val data: Any? = null, +) From 4cdb3821e98de4d82eff2b62b508911873c0b374 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Mon, 15 Jul 2024 01:00:22 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat(product):=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=81=B4=EB=9E=98=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shopping/common/domain/BaseTimeEntity.kt | 19 ++++++++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yaml | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml diff --git a/src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt b/src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt new file mode 100644 index 0000000..cf10759 --- /dev/null +++ b/src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt @@ -0,0 +1,19 @@ +package shopping.common.domain + +import jakarta.persistence.Column +import jakarta.persistence.MappedSuperclass +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.time.LocalDateTime + +@MappedSuperclass +abstract class BaseTimeEntity { + @CreationTimestamp + @Column + val createdAt: LocalDateTime? = null + + @UpdateTimestamp + @Column + var updatedAt: LocalDateTime? = null + protected set +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index c33078c..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=spring-shopping diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..1f39913 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,31 @@ +spring: + application: + name: spring-shopping + jpa: + open-in-view: false + hibernate: + ddl-auto: none + +--- + +spring: + config: + activate: + on-profile: local + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + + h2: + console: + enabled: true + + datasource: + jdbc-url: jdbc:h2:mem:testdb + username: sa + driver-class-name: org.h2.Driver From 3c8dab8ae50b7d25e5e07af21780b652a9090f95 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Mon, 15 Jul 2024 01:00:53 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++-- .../{BaseTimeEntity.kt => BaseEntity.kt} | 9 ++-- .../product/application/ProductCreateDtos.kt | 42 +++++++++++++++++++ .../product/application/ProductService.kt | 22 ++++++++++ .../product/controller/ProductController.kt | 26 ++++++++++++ .../product/domain/BadWordValidator.kt | 5 +++ .../domain/BadWordsCheckDomainService.kt | 8 ++++ .../kotlin/shopping/product/domain/Product.kt | 27 ++++++++++++ .../ProductNameContainBadWordException.kt | 11 +++++ .../product/domain/ProductRepository.kt | 5 +++ .../product/infra/PurgomalumClient.kt | 41 ++++++++++++++++++ 11 files changed, 197 insertions(+), 7 deletions(-) rename src/main/kotlin/shopping/common/domain/{BaseTimeEntity.kt => BaseEntity.kt} (71%) create mode 100644 src/main/kotlin/shopping/product/application/ProductCreateDtos.kt create mode 100644 src/main/kotlin/shopping/product/application/ProductService.kt create mode 100644 src/main/kotlin/shopping/product/controller/ProductController.kt create mode 100644 src/main/kotlin/shopping/product/domain/BadWordValidator.kt create mode 100644 src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt create mode 100644 src/main/kotlin/shopping/product/domain/Product.kt create mode 100644 src/main/kotlin/shopping/product/domain/ProductNameContainBadWordException.kt create mode 100644 src/main/kotlin/shopping/product/domain/ProductRepository.kt create mode 100644 src/main/kotlin/shopping/product/infra/PurgomalumClient.kt diff --git a/README.md b/README.md index 9f8ce46..2bee510 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # spring-shopping-product ## 기능요구사항 ### 상품 -- [ ] 상품 등록 api - - [ ] 공백 포함 15자 검사 - - [ ] 특정 특수문자 의외의 특수문자 존재하지 않는지 검사 - - [ ] 비속어 검사 +- [x] 상품 등록 api + - [x] 공백 포함 15자 검사 + - [x] 특정 특수문자 의외의 특수문자 존재하지 않는지 검사 + - [x] 비속어 검사 - [ ] 상품 조회 api - [ ] 상품 수정 api - [ ] 상품명 형식 검사 diff --git a/src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt b/src/main/kotlin/shopping/common/domain/BaseEntity.kt similarity index 71% rename from src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt rename to src/main/kotlin/shopping/common/domain/BaseEntity.kt index cf10759..f729535 100644 --- a/src/main/kotlin/shopping/common/domain/BaseTimeEntity.kt +++ b/src/main/kotlin/shopping/common/domain/BaseEntity.kt @@ -1,13 +1,16 @@ package shopping.common.domain -import jakarta.persistence.Column -import jakarta.persistence.MappedSuperclass +import jakarta.persistence.* import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp import java.time.LocalDateTime @MappedSuperclass -abstract class BaseTimeEntity { +abstract class BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0 + @CreationTimestamp @Column val createdAt: LocalDateTime? = null diff --git a/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt b/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt new file mode 100644 index 0000000..bc044bd --- /dev/null +++ b/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt @@ -0,0 +1,42 @@ +package shopping.product.application + +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import org.hibernate.validator.constraints.URL +import shopping.product.domain.MAX_PRODUCT_NAME_LENGTH +import shopping.product.domain.PRODUCT_NAME_PATTERN +import shopping.product.domain.Product + +data class CreateProductRequest( + @field:Size(min = 1, max = MAX_PRODUCT_NAME_LENGTH, message = "상품명은 1자 이상 ${MAX_PRODUCT_NAME_LENGTH}자 이하여야 합니다.") + @field:Pattern(regexp = PRODUCT_NAME_PATTERN, message = "허용되지 않은 특수문자가 포함되어있습니다.") + val name: String, + @field:Min(0) + val price: Int, + @field:URL + val imageUrl: String, + @field:Min(0) + val stockQuantity: Int, +) { + fun toProduct(): Product = + Product( + name = name, + price = price, + imageUrl = imageUrl, + ) +} + +data class CreateProductResponse( + val id: Long, + val name: String, + val price: Int, + val imageUrl: String, +) { + constructor(product: Product) : this( + id = product.id, + name = product.name, + price = product.price, + imageUrl = product.imageUrl, + ) +} diff --git a/src/main/kotlin/shopping/product/application/ProductService.kt b/src/main/kotlin/shopping/product/application/ProductService.kt new file mode 100644 index 0000000..6efd0e8 --- /dev/null +++ b/src/main/kotlin/shopping/product/application/ProductService.kt @@ -0,0 +1,22 @@ +package shopping.product.application + +import org.springframework.stereotype.Service +import shopping.product.domain.BadWordValidator +import shopping.product.domain.ProductNameContainBadWordException +import shopping.product.domain.ProductRepository + +@Service +class ProductService( + private val productRepository: ProductRepository, + private val badWordValidator: BadWordValidator, +) { + fun createProduct(request: CreateProductRequest): CreateProductResponse { + if (badWordValidator.isBadWord(request.name)) { + throw ProductNameContainBadWordException(request.name) + } + + val product = productRepository.save(request.toProduct()) + + return CreateProductResponse(product) + } +} diff --git a/src/main/kotlin/shopping/product/controller/ProductController.kt b/src/main/kotlin/shopping/product/controller/ProductController.kt new file mode 100644 index 0000000..1d7b72b --- /dev/null +++ b/src/main/kotlin/shopping/product/controller/ProductController.kt @@ -0,0 +1,26 @@ +package shopping.product.controller + +import jakarta.validation.Valid +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 shopping.common.api.ApiResponse +import shopping.product.application.CreateProductRequest +import shopping.product.application.CreateProductResponse +import shopping.product.application.ProductService + +@RestController +@RequestMapping("/products") +class ProductController( + private val productService: ProductService, +) { + @PostMapping + fun createProduct( + @Valid @RequestBody request: CreateProductRequest, + ): ApiResponse { + val response = productService.createProduct(request) + + return ApiResponse.success(response) + } +} diff --git a/src/main/kotlin/shopping/product/domain/BadWordValidator.kt b/src/main/kotlin/shopping/product/domain/BadWordValidator.kt new file mode 100644 index 0000000..06ca600 --- /dev/null +++ b/src/main/kotlin/shopping/product/domain/BadWordValidator.kt @@ -0,0 +1,5 @@ +package shopping.product.domain + +interface BadWordValidator { + fun isBadWord(text: String): Boolean +} diff --git a/src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt b/src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt new file mode 100644 index 0000000..e570290 --- /dev/null +++ b/src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt @@ -0,0 +1,8 @@ +package shopping.product.domain + +import org.springframework.stereotype.Service + +@Service +class BadWordsCheckDomainService { + fun isBadWords(text: String): Boolean = false +} diff --git a/src/main/kotlin/shopping/product/domain/Product.kt b/src/main/kotlin/shopping/product/domain/Product.kt new file mode 100644 index 0000000..37d6e37 --- /dev/null +++ b/src/main/kotlin/shopping/product/domain/Product.kt @@ -0,0 +1,27 @@ +package shopping.product.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import shopping.common.domain.BaseEntity + +const val MAX_PRODUCT_NAME_LENGTH: Int = 15 +const val PRODUCT_NAME_PATTERN: String = "^[a-zA-Z0-9 ()\\[\\]+\\-&/_]*$" + +@Entity +class Product( + name: String, + price: Int, + imageUrl: String, +) : BaseEntity() { + @Column(length = MAX_PRODUCT_NAME_LENGTH) + var name: String = name + protected set + + @Column + var price: Int = price + protected set + + @Column + var imageUrl: String = imageUrl + protected set +} diff --git a/src/main/kotlin/shopping/product/domain/ProductNameContainBadWordException.kt b/src/main/kotlin/shopping/product/domain/ProductNameContainBadWordException.kt new file mode 100644 index 0000000..db40416 --- /dev/null +++ b/src/main/kotlin/shopping/product/domain/ProductNameContainBadWordException.kt @@ -0,0 +1,11 @@ +package shopping.product.domain + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +class ProductNameContainBadWordException( + name: String, +) : ApiException( + errorCode = ErrorCode.PRODUCT_NAME_CONTAIN_BAD_WORD, + message = "상품명에 금지어가 포함되어 있습니다. (상품명: $name)", + ) diff --git a/src/main/kotlin/shopping/product/domain/ProductRepository.kt b/src/main/kotlin/shopping/product/domain/ProductRepository.kt new file mode 100644 index 0000000..3dd5072 --- /dev/null +++ b/src/main/kotlin/shopping/product/domain/ProductRepository.kt @@ -0,0 +1,5 @@ +package shopping.product.domain + +import org.springframework.data.jpa.repository.JpaRepository + +interface ProductRepository : JpaRepository diff --git a/src/main/kotlin/shopping/product/infra/PurgomalumClient.kt b/src/main/kotlin/shopping/product/infra/PurgomalumClient.kt new file mode 100644 index 0000000..6e12dfc --- /dev/null +++ b/src/main/kotlin/shopping/product/infra/PurgomalumClient.kt @@ -0,0 +1,41 @@ +package shopping.product.infra + +import org.slf4j.LoggerFactory +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.http.RequestEntity +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClientException +import org.springframework.web.client.exchange +import shopping.product.domain.BadWordValidator +import java.lang.RuntimeException + +@Component +class PurgomalumClient( + private val restTemplateBuilder: RestTemplateBuilder, +) : BadWordValidator { + private val logger = LoggerFactory.getLogger(PurgomalumClient::class.java) + private val restTemplate = restTemplateBuilder.build() + private val badWordMarker = '*' + + override fun isBadWord(text: String): Boolean { + val requestEntity = createRequestEntity(text) + + try { + logger.info("{}", requestEntity) + val response = restTemplate.exchange(requestEntity) + logger.info("{}", response) + val body = response.body ?: throw RuntimeException("Purgomalum API의 응답 body가 없음") + return body.contains(badWordMarker) + } catch (e: Exception) { + when (e) { + is RestClientException -> throw RuntimeException("Purgomalum API network error", e) + else -> throw RuntimeException("Purgomalum API 호출중 알수없는 예외 발생", e) + } + } + } + + private fun createRequestEntity(text: String) = + RequestEntity + .get("https://www.purgomalum.com/service/plain?text=$text") + .build() +} From 5d83893276587fccc304182eca168ce3b7df6647 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Mon, 15 Jul 2024 23:49:16 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +-- .../kotlin/shopping/common/error/ErrorCode.kt | 1 + .../application/ProductNotFoundException.kt | 8 +++++ .../product/application/ProductService.kt | 29 +++++++++++++-- .../product/application/ProductUpdateDtos.kt | 35 +++++++++++++++++++ .../product/controller/ProductController.kt | 16 +++++++-- .../kotlin/shopping/product/domain/Product.kt | 10 ++++++ 7 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/shopping/product/application/ProductNotFoundException.kt create mode 100644 src/main/kotlin/shopping/product/application/ProductUpdateDtos.kt diff --git a/README.md b/README.md index 2bee510..cfd9743 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ - [x] 특정 특수문자 의외의 특수문자 존재하지 않는지 검사 - [x] 비속어 검사 - [ ] 상품 조회 api -- [ ] 상품 수정 api - - [ ] 상품명 형식 검사 +- [x] 상품 수정 api + - [x] 상품명 형식 검사 - [ ] 상품 삭제 api ### 로그인 - [ ] 회원가입 diff --git a/src/main/kotlin/shopping/common/error/ErrorCode.kt b/src/main/kotlin/shopping/common/error/ErrorCode.kt index c111818..2d0d4be 100644 --- a/src/main/kotlin/shopping/common/error/ErrorCode.kt +++ b/src/main/kotlin/shopping/common/error/ErrorCode.kt @@ -6,5 +6,6 @@ enum class ErrorCode( VALIDATION_ERROR("C0001"), UNKNOWN_ERROR("C0002"), + PRODUCT_NOT_FOUND("P0000"), PRODUCT_NAME_CONTAIN_BAD_WORD("P0001"), } diff --git a/src/main/kotlin/shopping/product/application/ProductNotFoundException.kt b/src/main/kotlin/shopping/product/application/ProductNotFoundException.kt new file mode 100644 index 0000000..96eed2f --- /dev/null +++ b/src/main/kotlin/shopping/product/application/ProductNotFoundException.kt @@ -0,0 +1,8 @@ +package shopping.product.application + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +class ProductNotFoundException( + productId: Long, +) : ApiException(errorCode = ErrorCode.PRODUCT_NOT_FOUND, message = "상품 ID: ${productId}을 찾을 수 없습니다.") diff --git a/src/main/kotlin/shopping/product/application/ProductService.kt b/src/main/kotlin/shopping/product/application/ProductService.kt index 6efd0e8..e58fe49 100644 --- a/src/main/kotlin/shopping/product/application/ProductService.kt +++ b/src/main/kotlin/shopping/product/application/ProductService.kt @@ -1,5 +1,6 @@ package shopping.product.application +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import shopping.product.domain.BadWordValidator import shopping.product.domain.ProductNameContainBadWordException @@ -11,12 +12,34 @@ class ProductService( private val badWordValidator: BadWordValidator, ) { fun createProduct(request: CreateProductRequest): CreateProductResponse { - if (badWordValidator.isBadWord(request.name)) { - throw ProductNameContainBadWordException(request.name) - } + checkProductNameIsNotBadWord(request.name) val product = productRepository.save(request.toProduct()) return CreateProductResponse(product) } + + fun updateProduct( + productId: Long, + request: UpdateProductRequest, + ): UpdateProductResponse { + val product = productRepository.findByIdOrNull(productId) ?: throw ProductNotFoundException(productId) + + checkProductNameIsNotBadWord(request.name) + + product.update( + name = request.name, + price = request.price, + imageUrl = request.imageUrl, + ) + productRepository.save(product) + + return UpdateProductResponse(product) + } + + private fun checkProductNameIsNotBadWord(name: String) { + if (badWordValidator.isBadWord(name)) { + throw ProductNameContainBadWordException(name) + } + } } diff --git a/src/main/kotlin/shopping/product/application/ProductUpdateDtos.kt b/src/main/kotlin/shopping/product/application/ProductUpdateDtos.kt new file mode 100644 index 0000000..539baa9 --- /dev/null +++ b/src/main/kotlin/shopping/product/application/ProductUpdateDtos.kt @@ -0,0 +1,35 @@ +package shopping.product.application + +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import org.hibernate.validator.constraints.URL +import shopping.product.domain.MAX_PRODUCT_NAME_LENGTH +import shopping.product.domain.PRODUCT_NAME_PATTERN +import shopping.product.domain.Product + +data class UpdateProductRequest( + @field:Size(min = 1, max = MAX_PRODUCT_NAME_LENGTH, message = "상품명은 1자 이상 ${MAX_PRODUCT_NAME_LENGTH}자 이하여야 합니다.") + @field:Pattern(regexp = PRODUCT_NAME_PATTERN, message = "허용되지 않은 특수문자가 포함되어있습니다.") + val name: String, + @field:Min(0) + val price: Int, + @field:URL + val imageUrl: String, + @field:Min(0) + val stockQuantity: Int, +) + +data class UpdateProductResponse( + val productId: Long, + val name: String, + val price: Int, + val imageUrl: String, +) { + constructor(product: Product) : this( + productId = product.id, + name = product.name, + price = product.price, + imageUrl = product.imageUrl, + ) +} diff --git a/src/main/kotlin/shopping/product/controller/ProductController.kt b/src/main/kotlin/shopping/product/controller/ProductController.kt index 1d7b72b..2b13a0b 100644 --- a/src/main/kotlin/shopping/product/controller/ProductController.kt +++ b/src/main/kotlin/shopping/product/controller/ProductController.kt @@ -1,14 +1,14 @@ package shopping.product.controller import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import shopping.common.api.ApiResponse -import shopping.product.application.CreateProductRequest -import shopping.product.application.CreateProductResponse -import shopping.product.application.ProductService +import shopping.product.application.* @RestController @RequestMapping("/products") @@ -23,4 +23,14 @@ class ProductController( return ApiResponse.success(response) } + + @PutMapping("/{productId}") + fun updateProduct( + @Valid @RequestBody request: UpdateProductRequest, + @PathVariable(name = "productId") productId: Long, + ): ApiResponse { + val response = productService.updateProduct(productId = productId, request = request) + + return ApiResponse.success(response) + } } diff --git a/src/main/kotlin/shopping/product/domain/Product.kt b/src/main/kotlin/shopping/product/domain/Product.kt index 37d6e37..4543be5 100644 --- a/src/main/kotlin/shopping/product/domain/Product.kt +++ b/src/main/kotlin/shopping/product/domain/Product.kt @@ -24,4 +24,14 @@ class Product( @Column var imageUrl: String = imageUrl protected set + + fun update( + name: String, + price: Int, + imageUrl: String, + ) { + this.name = name + this.price = price + this.imageUrl = imageUrl + } } From 12a5e3ad84afb6df2bf0ab68f7569eab2b53b3aa Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Mon, 15 Jul 2024 23:55:13 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../application/ProductDetailResponse.kt | 22 +++++++++++++++++++ .../product/application/ProductService.kt | 6 +++++ .../product/controller/ProductController.kt | 10 +++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/shopping/product/application/ProductDetailResponse.kt diff --git a/README.md b/README.md index cfd9743..4e02e8f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - [x] 공백 포함 15자 검사 - [x] 특정 특수문자 의외의 특수문자 존재하지 않는지 검사 - [x] 비속어 검사 -- [ ] 상품 조회 api +- [x] 상품 조회 api - [x] 상품 수정 api - [x] 상품명 형식 검사 - [ ] 상품 삭제 api diff --git a/src/main/kotlin/shopping/product/application/ProductDetailResponse.kt b/src/main/kotlin/shopping/product/application/ProductDetailResponse.kt new file mode 100644 index 0000000..7f3503b --- /dev/null +++ b/src/main/kotlin/shopping/product/application/ProductDetailResponse.kt @@ -0,0 +1,22 @@ +package shopping.product.application + +import shopping.product.domain.Product +import java.time.LocalDateTime + +data class ProductDetailResponse( + val id: Long, + val name: String, + val price: Int, + val imageUrl: String, + val createdAt: LocalDateTime?, + val updatedAt: LocalDateTime?, +) { + constructor(product: Product) : this( + id = product.id, + name = product.name, + price = product.price, + imageUrl = product.imageUrl, + createdAt = product.createdAt, + updatedAt = product.updatedAt, + ) +} diff --git a/src/main/kotlin/shopping/product/application/ProductService.kt b/src/main/kotlin/shopping/product/application/ProductService.kt index e58fe49..196a5a7 100644 --- a/src/main/kotlin/shopping/product/application/ProductService.kt +++ b/src/main/kotlin/shopping/product/application/ProductService.kt @@ -37,6 +37,12 @@ class ProductService( return UpdateProductResponse(product) } + fun getProduct(productId: Long): ProductDetailResponse { + val product = productRepository.findByIdOrNull(productId) ?: throw ProductNotFoundException(productId) + + return ProductDetailResponse(product) + } + private fun checkProductNameIsNotBadWord(name: String) { if (badWordValidator.isBadWord(name)) { throw ProductNameContainBadWordException(name) diff --git a/src/main/kotlin/shopping/product/controller/ProductController.kt b/src/main/kotlin/shopping/product/controller/ProductController.kt index 2b13a0b..a5713ab 100644 --- a/src/main/kotlin/shopping/product/controller/ProductController.kt +++ b/src/main/kotlin/shopping/product/controller/ProductController.kt @@ -1,6 +1,7 @@ package shopping.product.controller import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping @@ -33,4 +34,13 @@ class ProductController( return ApiResponse.success(response) } + + @GetMapping("/{productId}") + fun getProduct( + @PathVariable(name = "productId") productId: Long, + ): ApiResponse { + val response = productService.getProduct(productId) + + return ApiResponse.success(response) + } } From c8551bbfe8cee89fce68bd43a9d0088772f214c5 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Tue, 16 Jul 2024 00:29:36 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../shopping/product/application/ProductService.kt | 5 +++++ .../shopping/product/controller/ProductController.kt | 10 ++++++++++ src/main/kotlin/shopping/product/domain/Product.kt | 9 +++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e02e8f..577f92e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - [x] 상품 조회 api - [x] 상품 수정 api - [x] 상품명 형식 검사 -- [ ] 상품 삭제 api +- [x] 상품 삭제 api ### 로그인 - [ ] 회원가입 - [ ] 이메일 중복 검사 diff --git a/src/main/kotlin/shopping/product/application/ProductService.kt b/src/main/kotlin/shopping/product/application/ProductService.kt index 196a5a7..351e4ef 100644 --- a/src/main/kotlin/shopping/product/application/ProductService.kt +++ b/src/main/kotlin/shopping/product/application/ProductService.kt @@ -43,6 +43,11 @@ class ProductService( return ProductDetailResponse(product) } + fun deleteProduct(productId: Long) { + val product = productRepository.findByIdOrNull(productId) ?: throw ProductNotFoundException(productId) + productRepository.delete(product) + } + private fun checkProductNameIsNotBadWord(name: String) { if (badWordValidator.isBadWord(name)) { throw ProductNameContainBadWordException(name) diff --git a/src/main/kotlin/shopping/product/controller/ProductController.kt b/src/main/kotlin/shopping/product/controller/ProductController.kt index a5713ab..59f27bc 100644 --- a/src/main/kotlin/shopping/product/controller/ProductController.kt +++ b/src/main/kotlin/shopping/product/controller/ProductController.kt @@ -1,6 +1,7 @@ package shopping.product.controller import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -43,4 +44,13 @@ class ProductController( return ApiResponse.success(response) } + + @DeleteMapping("/{productId}") + fun deleteProduct( + @PathVariable(name = "productId") productId: Long, + ): ApiResponse { + productService.deleteProduct(productId) + + return ApiResponse.success() + } } diff --git a/src/main/kotlin/shopping/product/domain/Product.kt b/src/main/kotlin/shopping/product/domain/Product.kt index 4543be5..12c024c 100644 --- a/src/main/kotlin/shopping/product/domain/Product.kt +++ b/src/main/kotlin/shopping/product/domain/Product.kt @@ -2,11 +2,16 @@ package shopping.product.domain import jakarta.persistence.Column import jakarta.persistence.Entity +import org.hibernate.annotations.SQLDelete +import org.hibernate.annotations.SQLRestriction import shopping.common.domain.BaseEntity +import java.time.LocalDateTime const val MAX_PRODUCT_NAME_LENGTH: Int = 15 const val PRODUCT_NAME_PATTERN: String = "^[a-zA-Z0-9 ()\\[\\]+\\-&/_]*$" +@SQLDelete(sql = "update product set deleted_at = CURRENT_TIMESTAMP where id = ?") +@SQLRestriction(value = "deleted_at is null") @Entity class Product( name: String, @@ -25,6 +30,10 @@ class Product( var imageUrl: String = imageUrl protected set + @Column + var deletedAt: LocalDateTime? = null + protected set + fun update( name: String, price: Int, From 08b11c6a75716dec435b8cff7fa690550302298a Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Wed, 17 Jul 2024 01:30:02 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat(common):=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=95=88?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shopping/common/api/GlobalErrorHandler.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt index fa2e20a..9409f38 100644 --- a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt +++ b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt @@ -1,9 +1,12 @@ package shopping.common.api +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.MismatchedInputException import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.validation.FieldError import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler @@ -16,8 +19,30 @@ import shopping.common.error.ErrorMessage class GlobalErrorHandler { private val logger: Logger = LoggerFactory.getLogger(GlobalErrorHandler::class.java) + @ExceptionHandler(HttpMessageNotReadableException::class) + fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException): ResponseEntity> { + logger.error("HttpMessageNotReadableException: {}", e.message, e) + val data = + when (val causeException = e.cause) { + is MismatchedInputException -> causeException.path[0].toData() + else -> null + } + + val errorMessage = + ErrorMessage( + code = ErrorCode.VALIDATION_ERROR, + message = e.message ?: "Missing field error", + data = data, + ) + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(errorMessage)) + } + @ExceptionHandler(MethodArgumentNotValidException::class) fun requestValidationExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity> { + logger.error("MethodArgumentNotValidException: {}", e.message, e) val errorMessage = ErrorMessage( code = ErrorCode.VALIDATION_ERROR, @@ -66,3 +91,10 @@ private fun FieldError.toData() = "rejectedValue" to rejectedValue, "message" to defaultMessage, ) + +private fun JsonMappingException.Reference.toData() = + mapOf( + "field" to fieldName, + "rejectedValue" to null, + "message" to "null 이면 안됩니다.", + ) From cd6ac869a9e4d406dc5e202f5814e4c20672b020 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Wed, 17 Jul 2024 01:32:08 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat(user):=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++--- build.gradle.kts | 3 ++ .../kotlin/shopping/common/error/ErrorCode.kt | 2 ++ .../kotlin/shopping/common/util/AuthUtil.kt | 12 +++++++ .../shopping/common/util/JwtProvider.kt | 32 +++++++++++++++++++ .../user/application/UserRegistDtos.kt | 20 ++++++++++++ .../shopping/user/application/UserService.kt | 31 ++++++++++++++++++ .../user/controller/UserController.kt | 26 +++++++++++++++ .../shopping/user/domain/EncryptedPassword.kt | 21 ++++++++++++ src/main/kotlin/shopping/user/domain/User.kt | 19 +++++++++++ .../domain/UserAlreadyRegisteredException.kt | 11 +++++++ .../shopping/user/domain/UserRepository.kt | 7 ++++ src/main/resources/application.yaml | 5 +++ 13 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/shopping/common/util/AuthUtil.kt create mode 100644 src/main/kotlin/shopping/common/util/JwtProvider.kt create mode 100644 src/main/kotlin/shopping/user/application/UserRegistDtos.kt create mode 100644 src/main/kotlin/shopping/user/application/UserService.kt create mode 100644 src/main/kotlin/shopping/user/controller/UserController.kt create mode 100644 src/main/kotlin/shopping/user/domain/EncryptedPassword.kt create mode 100644 src/main/kotlin/shopping/user/domain/User.kt create mode 100644 src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt create mode 100644 src/main/kotlin/shopping/user/domain/UserRepository.kt diff --git a/README.md b/README.md index 577f92e..231f6e0 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ - [x] 상품명 형식 검사 - [x] 상품 삭제 api ### 로그인 -- [ ] 회원가입 - - [ ] 이메일 중복 검사 - - [ ] 이메일 형식 검사 - - [ ] 비밀번호 암호화 - - [ ] 비밀번호 형식 검사 +- [x] 회원가입 + - [x] 이메일 중복 검사 + - [x] 이메일 형식 검사 + - [x] 비밀번호 암호화 + - [x] 비밀번호 형식 검사 - [ ] 로그인 - [ ] 로그인시 토큰 발급 api(엑세스, 리프레시 토큰) - [ ] 엑세스 토큰 재발급 api diff --git a/build.gradle.kts b/build.gradle.kts index 3f75395..4e417c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,9 @@ dependencies { implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-mysql") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/kotlin/shopping/common/error/ErrorCode.kt b/src/main/kotlin/shopping/common/error/ErrorCode.kt index 2d0d4be..4af06a6 100644 --- a/src/main/kotlin/shopping/common/error/ErrorCode.kt +++ b/src/main/kotlin/shopping/common/error/ErrorCode.kt @@ -8,4 +8,6 @@ enum class ErrorCode( PRODUCT_NOT_FOUND("P0000"), PRODUCT_NAME_CONTAIN_BAD_WORD("P0001"), + + USER_ALREADY_REGISTERED("U0000"), } diff --git a/src/main/kotlin/shopping/common/util/AuthUtil.kt b/src/main/kotlin/shopping/common/util/AuthUtil.kt new file mode 100644 index 0000000..bd44624 --- /dev/null +++ b/src/main/kotlin/shopping/common/util/AuthUtil.kt @@ -0,0 +1,12 @@ +package shopping.common.util + +import java.security.MessageDigest + +object AuthUtil { + private val sha256Digest = MessageDigest.getInstance("SHA-256") + + fun encryptSha256(text: String): String = + sha256Digest.digest(text.toByteArray()).joinToString("") { + "%02x".format(it) + } +} diff --git a/src/main/kotlin/shopping/common/util/JwtProvider.kt b/src/main/kotlin/shopping/common/util/JwtProvider.kt new file mode 100644 index 0000000..76e082f --- /dev/null +++ b/src/main/kotlin/shopping/common/util/JwtProvider.kt @@ -0,0 +1,32 @@ +package shopping.common.util + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.Date + +const val ONE_WEEK_MILLISECOND: Long = 1000 * 60 * 60 * 24 * 7 + +@Component +class JwtProvider( + @Value("\${jwt.secret-key}") + private val secretKey: String, + @Value("\${jwt.expiration-millis-second}") + private val expirationMillisSecond: Long = ONE_WEEK_MILLISECOND, +) { + private val signingKey = Keys.hmacShaKeyFor(secretKey.toByteArray()) + + fun createToken(subject: String): String { + val now = Date() + val expireAt = Date(now.time + expirationMillisSecond) + + return Jwts + .builder() + .subject(subject) + .issuedAt(now) + .expiration(expireAt) + .signWith(signingKey) + .compact() + } +} diff --git a/src/main/kotlin/shopping/user/application/UserRegistDtos.kt b/src/main/kotlin/shopping/user/application/UserRegistDtos.kt new file mode 100644 index 0000000..b104e5a --- /dev/null +++ b/src/main/kotlin/shopping/user/application/UserRegistDtos.kt @@ -0,0 +1,20 @@ +package shopping.user.application + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import shopping.user.domain.MAX_PASSWORD_LENGTH +import shopping.user.domain.MIN_PASSWORD_LENGTH + +data class UserRegistRequest( + @field:Email + val email: String, + @field:Size(min = MIN_PASSWORD_LENGTH, max = MAX_PASSWORD_LENGTH) + val password: String, + @field:NotBlank + val name: String, +) + +data class UserRegistResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/shopping/user/application/UserService.kt b/src/main/kotlin/shopping/user/application/UserService.kt new file mode 100644 index 0000000..a10e889 --- /dev/null +++ b/src/main/kotlin/shopping/user/application/UserService.kt @@ -0,0 +1,31 @@ +package shopping.user.application + +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service +import shopping.common.util.JwtProvider +import shopping.user.domain.EncryptedPassword +import shopping.user.domain.User +import shopping.user.domain.UserAlreadyRegisteredException +import shopping.user.domain.UserRepository + +@Service +class UserService( + private val userRepository: UserRepository, + private val jwtProvider: JwtProvider, +) { + @Transactional + fun regist(request: UserRegistRequest): UserRegistResponse { + if (userRepository.existsByEmail(request.email)) { + throw UserAlreadyRegisteredException(request.email) + } + + User( + email = request.email, + password = EncryptedPassword.from(request.password), + ).let { userRepository.save(it) } + + val jwt = jwtProvider.createToken(request.email) + + return UserRegistResponse(accessToken = jwt) + } +} diff --git a/src/main/kotlin/shopping/user/controller/UserController.kt b/src/main/kotlin/shopping/user/controller/UserController.kt new file mode 100644 index 0000000..1f51ed1 --- /dev/null +++ b/src/main/kotlin/shopping/user/controller/UserController.kt @@ -0,0 +1,26 @@ +package shopping.user.controller + +import jakarta.validation.Valid +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 shopping.common.api.ApiResponse +import shopping.user.application.UserRegistRequest +import shopping.user.application.UserRegistResponse +import shopping.user.application.UserService + +@RestController +@RequestMapping("/users") +class UserController( + private val userService: UserService, +) { + @PostMapping("/regist") + fun regist( + @Valid @RequestBody request: UserRegistRequest, + ): ApiResponse { + val response = userService.regist(request) + + return ApiResponse.success(response) + } +} diff --git a/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt b/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt new file mode 100644 index 0000000..cdb92fd --- /dev/null +++ b/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt @@ -0,0 +1,21 @@ +package shopping.user.domain + +import shopping.common.util.AuthUtil + +const val MIN_PASSWORD_LENGTH = 10 +const val MAX_PASSWORD_LENGTH = 20 + +@JvmInline +value class EncryptedPassword private constructor( + private val value: String, +) { + companion object { + fun from(plainPassword: String): EncryptedPassword { + require(plainPassword.length in MIN_PASSWORD_LENGTH..MAX_PASSWORD_LENGTH) { + "비밀번호는 $MIN_PASSWORD_LENGTH~$MAX_PASSWORD_LENGTH 길이여야 합니다." + } + + return EncryptedPassword(AuthUtil.encryptSha256(plainPassword)) + } + } +} diff --git a/src/main/kotlin/shopping/user/domain/User.kt b/src/main/kotlin/shopping/user/domain/User.kt new file mode 100644 index 0000000..45c93a5 --- /dev/null +++ b/src/main/kotlin/shopping/user/domain/User.kt @@ -0,0 +1,19 @@ +package shopping.user.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import shopping.common.domain.BaseEntity + +@Entity +@Table(name = "users") +class User( + email: String, + password: EncryptedPassword, +) : BaseEntity() { + @Column(unique = true) + val email: String = email + + @Column + var password: EncryptedPassword = password +} diff --git a/src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt b/src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt new file mode 100644 index 0000000..a2de862 --- /dev/null +++ b/src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt @@ -0,0 +1,11 @@ +package shopping.user.domain + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +class UserAlreadyRegisteredException( + email: String, +) : ApiException( + errorCode = ErrorCode.USER_ALREADY_REGISTERED, + message = "email: $email 이미 등록된 사용자입니다.", + ) diff --git a/src/main/kotlin/shopping/user/domain/UserRepository.kt b/src/main/kotlin/shopping/user/domain/UserRepository.kt new file mode 100644 index 0000000..ab85e04 --- /dev/null +++ b/src/main/kotlin/shopping/user/domain/UserRepository.kt @@ -0,0 +1,7 @@ +package shopping.user.domain + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + fun existsByEmail(email: String): Boolean +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1f39913..36baba9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -6,6 +6,11 @@ spring: hibernate: ddl-auto: none +jwt: + secret-key: "spring-shopping-user-k!@#@!#@!#!@#!@#!@#!@#!@y" + # 1week = 1000 * 60 * 60 * 24 * 7 + expiration-millis-second: 604800000 + --- spring: From 2f443acfb7f00d60c638c6b8417cde33dd62ec19 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Wed, 17 Jul 2024 01:56:10 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat(user):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/shopping/common/error/ErrorCode.kt | 2 ++ .../shopping/user/application/UserLoginDtos.kt | 17 +++++++++++++++++ .../user/application/UserNotFoundException.kt | 11 +++++++++++ .../shopping/user/application/UserService.kt | 15 +++++++++++++++ .../shopping/user/controller/UserController.kt | 11 +++++++++++ .../user/domain/PasswordMismatchException.kt | 10 ++++++++++ .../shopping/user/domain/UserRepository.kt | 2 ++ 8 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/shopping/user/application/UserLoginDtos.kt create mode 100644 src/main/kotlin/shopping/user/application/UserNotFoundException.kt create mode 100644 src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt diff --git a/README.md b/README.md index 231f6e0..2affbdf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - [x] 비밀번호 암호화 - [x] 비밀번호 형식 검사 - [ ] 로그인 - - [ ] 로그인시 토큰 발급 api(엑세스, 리프레시 토큰) + - [x] 로그인시 토큰 발급 api - [ ] 엑세스 토큰 재발급 api ### 위시리스트 - [ ] 위시리스트 상품목록 조회 api diff --git a/src/main/kotlin/shopping/common/error/ErrorCode.kt b/src/main/kotlin/shopping/common/error/ErrorCode.kt index 4af06a6..d4eac30 100644 --- a/src/main/kotlin/shopping/common/error/ErrorCode.kt +++ b/src/main/kotlin/shopping/common/error/ErrorCode.kt @@ -10,4 +10,6 @@ enum class ErrorCode( PRODUCT_NAME_CONTAIN_BAD_WORD("P0001"), USER_ALREADY_REGISTERED("U0000"), + USER_NOT_FOUND("U0001"), + PASSWORD_MISMATCH("U0002"), } diff --git a/src/main/kotlin/shopping/user/application/UserLoginDtos.kt b/src/main/kotlin/shopping/user/application/UserLoginDtos.kt new file mode 100644 index 0000000..808d803 --- /dev/null +++ b/src/main/kotlin/shopping/user/application/UserLoginDtos.kt @@ -0,0 +1,17 @@ +package shopping.user.application + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Size +import shopping.user.domain.MAX_PASSWORD_LENGTH +import shopping.user.domain.MIN_PASSWORD_LENGTH + +data class UserLoginRequest( + @field:Email + val email: String, + @field:Size(min = MIN_PASSWORD_LENGTH, max = MAX_PASSWORD_LENGTH) + val password: String, +) + +data class UserLoginResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/shopping/user/application/UserNotFoundException.kt b/src/main/kotlin/shopping/user/application/UserNotFoundException.kt new file mode 100644 index 0000000..346fe79 --- /dev/null +++ b/src/main/kotlin/shopping/user/application/UserNotFoundException.kt @@ -0,0 +1,11 @@ +package shopping.user.application + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +class UserNotFoundException( + email: String, +) : ApiException( + errorCode = ErrorCode.USER_NOT_FOUND, + message = "email: $email 사용자를 찾을 수 없습니다.", + ) diff --git a/src/main/kotlin/shopping/user/application/UserService.kt b/src/main/kotlin/shopping/user/application/UserService.kt index a10e889..f50a316 100644 --- a/src/main/kotlin/shopping/user/application/UserService.kt +++ b/src/main/kotlin/shopping/user/application/UserService.kt @@ -4,6 +4,7 @@ import jakarta.transaction.Transactional import org.springframework.stereotype.Service import shopping.common.util.JwtProvider import shopping.user.domain.EncryptedPassword +import shopping.user.domain.PasswordMismatchException import shopping.user.domain.User import shopping.user.domain.UserAlreadyRegisteredException import shopping.user.domain.UserRepository @@ -28,4 +29,18 @@ class UserService( return UserRegistResponse(accessToken = jwt) } + + fun login(request: UserLoginRequest): UserLoginResponse { + val user = + userRepository.findByEmail(request.email) + ?: throw UserNotFoundException(request.email) + + if (user.password != EncryptedPassword.from(request.password)) { + throw PasswordMismatchException() + } + + val jwt = jwtProvider.createToken(request.email) + + return UserLoginResponse(accessToken = jwt) + } } diff --git a/src/main/kotlin/shopping/user/controller/UserController.kt b/src/main/kotlin/shopping/user/controller/UserController.kt index 1f51ed1..cd062a1 100644 --- a/src/main/kotlin/shopping/user/controller/UserController.kt +++ b/src/main/kotlin/shopping/user/controller/UserController.kt @@ -6,6 +6,8 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import shopping.common.api.ApiResponse +import shopping.user.application.UserLoginRequest +import shopping.user.application.UserLoginResponse import shopping.user.application.UserRegistRequest import shopping.user.application.UserRegistResponse import shopping.user.application.UserService @@ -23,4 +25,13 @@ class UserController( return ApiResponse.success(response) } + + @PostMapping("/login") + fun login( + @Valid @RequestBody request: UserLoginRequest, + ): ApiResponse { + val response = userService.login(request) + + return ApiResponse.success(response) + } } diff --git a/src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt b/src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt new file mode 100644 index 0000000..5366eba --- /dev/null +++ b/src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt @@ -0,0 +1,10 @@ +package shopping.user.domain + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +class PasswordMismatchException : + ApiException( + errorCode = ErrorCode.PASSWORD_MISMATCH, + message = "비밀번호가 일치하지 않습니다.", + ) diff --git a/src/main/kotlin/shopping/user/domain/UserRepository.kt b/src/main/kotlin/shopping/user/domain/UserRepository.kt index ab85e04..7b3d409 100644 --- a/src/main/kotlin/shopping/user/domain/UserRepository.kt +++ b/src/main/kotlin/shopping/user/domain/UserRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserRepository : JpaRepository { fun existsByEmail(email: String): Boolean + + fun findByEmail(email: String): User? } From c74c88dc4633c55a566a5cd1e378f9b8420d9974 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Fri, 19 Jul 2024 04:08:22 +0900 Subject: [PATCH 11/29] =?UTF-8?q?refactor(common):=20code=20->=20errorCode?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=EB=AA=85=20=EB=AA=85=ED=99=95=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shopping/common/api/GlobalErrorHandler.kt | 25 +++++++++++++++---- .../shopping/common/error/ErrorMessage.kt | 8 +++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt index 9409f38..b95c6b4 100644 --- a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt +++ b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt @@ -14,11 +14,26 @@ import org.springframework.web.bind.annotation.RestControllerAdvice import shopping.common.error.ApiException import shopping.common.error.ErrorCode import shopping.common.error.ErrorMessage +import shopping.common.error.LoginFailedException @RestControllerAdvice class GlobalErrorHandler { private val logger: Logger = LoggerFactory.getLogger(GlobalErrorHandler::class.java) + @ExceptionHandler(LoginFailedException::class) + fun loginFailedExceptionHandler(e: LoginFailedException): ResponseEntity> { + logger.error("LoginFailedException: {}", e.message, e) + val errorMessage = + ErrorMessage( + errorCode = ErrorCode.LOGIN_FAILED, + message = e.message, + ) + + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(errorMessage)) + } + @ExceptionHandler(HttpMessageNotReadableException::class) fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException): ResponseEntity> { logger.error("HttpMessageNotReadableException: {}", e.message, e) @@ -30,7 +45,7 @@ class GlobalErrorHandler { val errorMessage = ErrorMessage( - code = ErrorCode.VALIDATION_ERROR, + errorCode = ErrorCode.VALIDATION_ERROR, message = e.message ?: "Missing field error", data = data, ) @@ -45,7 +60,7 @@ class GlobalErrorHandler { logger.error("MethodArgumentNotValidException: {}", e.message, e) val errorMessage = ErrorMessage( - code = ErrorCode.VALIDATION_ERROR, + errorCode = ErrorCode.VALIDATION_ERROR, message = e.message, data = e.bindingResult.fieldErrors.map { it.toData() }, ) @@ -60,13 +75,13 @@ class GlobalErrorHandler { logger.warn("ApiException: {}", e.message, e) val errorMessage = ErrorMessage( - code = e.errorCode, + errorCode = e.errorCode, message = e.message, data = e.data, ) return ResponseEntity - .ok() + .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.error(errorMessage)) } @@ -75,7 +90,7 @@ class GlobalErrorHandler { logger.error("Exception: {}", e.message, e) val errorMessage = ErrorMessage( - code = ErrorCode.UNKNOWN_ERROR, + errorCode = ErrorCode.UNKNOWN_ERROR, message = e.message ?: "Unknown Error", ) diff --git a/src/main/kotlin/shopping/common/error/ErrorMessage.kt b/src/main/kotlin/shopping/common/error/ErrorMessage.kt index be654e4..3085f84 100644 --- a/src/main/kotlin/shopping/common/error/ErrorMessage.kt +++ b/src/main/kotlin/shopping/common/error/ErrorMessage.kt @@ -1,7 +1,9 @@ package shopping.common.error -data class ErrorMessage( - val code: ErrorCode, +data class ErrorMessage private constructor( + val code: String, val message: String, val data: Any? = null, -) +) { + constructor(errorCode: ErrorCode, message: String, data: Any? = null) : this(errorCode.code, message, data) +} From e6b6ba00d96c94fb5bd9c9b31b64e943b6ba4f8f Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Fri, 19 Jul 2024 04:09:06 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat(common):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/api/CurrentUserArgumentResolver.kt | 52 +++++++++++++++++++ .../shopping/common/domain/CurrentUser.kt | 6 +++ .../shopping/common/error/ApiException.kt | 3 +- .../kotlin/shopping/common/error/ErrorCode.kt | 1 + .../common/error/LoginFailedException.kt | 10 ++++ .../shopping/common/util/JwtProvider.kt | 9 ++++ src/main/kotlin/shopping/config/WebConfig.kt | 15 ++++++ .../user/application/UserNotFoundException.kt | 14 +++-- .../shopping/user/application/UserService.kt | 2 +- 9 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt create mode 100644 src/main/kotlin/shopping/common/domain/CurrentUser.kt create mode 100644 src/main/kotlin/shopping/common/error/LoginFailedException.kt create mode 100644 src/main/kotlin/shopping/config/WebConfig.kt diff --git a/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt b/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt new file mode 100644 index 0000000..d43ab3f --- /dev/null +++ b/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt @@ -0,0 +1,52 @@ +package shopping.common.api + +import io.jsonwebtoken.JwtException +import org.springframework.core.MethodParameter +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import shopping.common.domain.CurrentUser +import shopping.common.error.LoginFailedException +import shopping.common.util.JwtProvider +import shopping.user.application.UserNotFoundException +import shopping.user.domain.UserRepository + +@Component +class CurrentUserArgumentResolver( + private val jwtProvider: JwtProvider, + private val userRepository: UserRepository, +) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == CurrentUser::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any? { + val token = getToken(webRequest) + val email = getEmail(token) + + userRepository.findByEmail(email)?.let { + return CurrentUser(it.id, it.email) + } ?: throw UserNotFoundException.fromEmail(email) + } + + private fun getEmail(token: String) = + try { + jwtProvider.getSubject(token) + } catch (e: Exception) { + when (e) { + is JwtException, is IllegalArgumentException -> throw LoginFailedException("인증정보가 유효하지 않습니다.", e) + else -> throw e + } + } + + private fun getToken(webRequest: NativeWebRequest): String { + val token = webRequest.getHeader(AUTHORIZATION) ?: throw LoginFailedException("인증정보가 없습니다.") + return token.split(" ").getOrNull(1) ?: throw LoginFailedException("인증정보가 유효하지 않습니다.") + } +} diff --git a/src/main/kotlin/shopping/common/domain/CurrentUser.kt b/src/main/kotlin/shopping/common/domain/CurrentUser.kt new file mode 100644 index 0000000..8d687a0 --- /dev/null +++ b/src/main/kotlin/shopping/common/domain/CurrentUser.kt @@ -0,0 +1,6 @@ +package shopping.common.domain + +data class CurrentUser( + val id: Long, + val email: String, +) diff --git a/src/main/kotlin/shopping/common/error/ApiException.kt b/src/main/kotlin/shopping/common/error/ApiException.kt index 5889416..fd8174c 100644 --- a/src/main/kotlin/shopping/common/error/ApiException.kt +++ b/src/main/kotlin/shopping/common/error/ApiException.kt @@ -4,4 +4,5 @@ open class ApiException( val errorCode: ErrorCode, override val message: String, val data: Any? = null, -) : RuntimeException() + cause: Throwable? = null, +) : RuntimeException(cause) diff --git a/src/main/kotlin/shopping/common/error/ErrorCode.kt b/src/main/kotlin/shopping/common/error/ErrorCode.kt index d4eac30..7589b02 100644 --- a/src/main/kotlin/shopping/common/error/ErrorCode.kt +++ b/src/main/kotlin/shopping/common/error/ErrorCode.kt @@ -5,6 +5,7 @@ enum class ErrorCode( ) { VALIDATION_ERROR("C0001"), UNKNOWN_ERROR("C0002"), + LOGIN_FAILED("C0003"), PRODUCT_NOT_FOUND("P0000"), PRODUCT_NAME_CONTAIN_BAD_WORD("P0001"), diff --git a/src/main/kotlin/shopping/common/error/LoginFailedException.kt b/src/main/kotlin/shopping/common/error/LoginFailedException.kt new file mode 100644 index 0000000..f997f6f --- /dev/null +++ b/src/main/kotlin/shopping/common/error/LoginFailedException.kt @@ -0,0 +1,10 @@ +package shopping.common.error + +class LoginFailedException( + message: String, + cause: Throwable? = null, +) : ApiException( + errorCode = ErrorCode.LOGIN_FAILED, + message = message, + cause = cause, + ) diff --git a/src/main/kotlin/shopping/common/util/JwtProvider.kt b/src/main/kotlin/shopping/common/util/JwtProvider.kt index 76e082f..9a54378 100644 --- a/src/main/kotlin/shopping/common/util/JwtProvider.kt +++ b/src/main/kotlin/shopping/common/util/JwtProvider.kt @@ -29,4 +29,13 @@ class JwtProvider( .signWith(signingKey) .compact() } + + fun getSubject(token: String): String = + Jwts + .parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .payload + .subject } diff --git a/src/main/kotlin/shopping/config/WebConfig.kt b/src/main/kotlin/shopping/config/WebConfig.kt new file mode 100644 index 0000000..b339a2f --- /dev/null +++ b/src/main/kotlin/shopping/config/WebConfig.kt @@ -0,0 +1,15 @@ +package shopping.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import shopping.common.api.CurrentUserArgumentResolver + +@Configuration +class WebConfig( + private val currentUserArgumentResolver: CurrentUserArgumentResolver, +) : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(currentUserArgumentResolver) + } +} diff --git a/src/main/kotlin/shopping/user/application/UserNotFoundException.kt b/src/main/kotlin/shopping/user/application/UserNotFoundException.kt index 346fe79..eb8ab99 100644 --- a/src/main/kotlin/shopping/user/application/UserNotFoundException.kt +++ b/src/main/kotlin/shopping/user/application/UserNotFoundException.kt @@ -3,9 +3,15 @@ package shopping.user.application import shopping.common.error.ApiException import shopping.common.error.ErrorCode -class UserNotFoundException( - email: String, +class UserNotFoundException private constructor( + message: String, ) : ApiException( errorCode = ErrorCode.USER_NOT_FOUND, - message = "email: $email 사용자를 찾을 수 없습니다.", - ) + message = message, + ) { + companion object { + fun fromEmail(email: String): UserNotFoundException = UserNotFoundException("email: $email 사용자를 찾을 수 없습니다.") + + fun fromId(id: Long): UserNotFoundException = UserNotFoundException("id: $id 사용자를 찾을 수 없습니다.") + } +} diff --git a/src/main/kotlin/shopping/user/application/UserService.kt b/src/main/kotlin/shopping/user/application/UserService.kt index f50a316..2e810ca 100644 --- a/src/main/kotlin/shopping/user/application/UserService.kt +++ b/src/main/kotlin/shopping/user/application/UserService.kt @@ -33,7 +33,7 @@ class UserService( fun login(request: UserLoginRequest): UserLoginResponse { val user = userRepository.findByEmail(request.email) - ?: throw UserNotFoundException(request.email) + ?: throw UserNotFoundException.fromEmail(request.email) if (user.password != EncryptedPassword.from(request.password)) { throw PasswordMismatchException() From 5645d36aceb454127e7babe0ed39f1877f2576da Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Fri, 19 Jul 2024 04:11:02 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat(wishlist):=20=EC=9C=84=EC=8B=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++-- .../wishlist/application/WishlistAddDtos.kt | 18 ++++++++++ .../wishlist/application/WishlistService.kt | 34 ++++++++++++++++++ .../wishlist/controller/WishlistController.kt | 28 +++++++++++++++ .../wishlist/domain/WishlistProduct.kt | 36 +++++++++++++++++++ .../domain/WishlistProductRepository.kt | 5 +++ 6 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/shopping/wishlist/application/WishlistAddDtos.kt create mode 100644 src/main/kotlin/shopping/wishlist/application/WishlistService.kt create mode 100644 src/main/kotlin/shopping/wishlist/controller/WishlistController.kt create mode 100644 src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt create mode 100644 src/main/kotlin/shopping/wishlist/domain/WishlistProductRepository.kt diff --git a/README.md b/README.md index 2affbdf..fb7b447 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ - [ ] 엑세스 토큰 재발급 api ### 위시리스트 - [ ] 위시리스트 상품목록 조회 api -- [ ] 위시리스트 상품 추가 api - - [ ] 중복 추가 방지 - - [ ] 상품 존재 여부 검사 +- [x] 위시리스트 상품 추가 api + - [x] 중복 추가 방지 + - [x] 상품 존재 여부 검사 - [ ] 위시리스트 상품 삭제 api - [ ] 상품 존재 여부 검사 diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistAddDtos.kt b/src/main/kotlin/shopping/wishlist/application/WishlistAddDtos.kt new file mode 100644 index 0000000..fb59aff --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/application/WishlistAddDtos.kt @@ -0,0 +1,18 @@ +package shopping.wishlist.application + +import shopping.wishlist.domain.WishlistProduct +import java.time.LocalDateTime + +data class AddWishlistRequest( + val productId: Long, +) + +data class AddWishlistResponse( + val productId: Long, + val createAt: LocalDateTime?, +) { + constructor(wishlistProduct: WishlistProduct) : this( + productId = wishlistProduct.id.productId, + createAt = wishlistProduct.createdAt, + ) +} diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt new file mode 100644 index 0000000..69962d0 --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt @@ -0,0 +1,34 @@ +package shopping.wishlist.application + +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import shopping.common.domain.CurrentUser +import shopping.product.application.ProductNotFoundException +import shopping.product.domain.ProductRepository +import shopping.wishlist.domain.WishlistProduct +import shopping.wishlist.domain.WishlistProductRepository + +@Service +class WishlistService( + private val wishlistProductRepository: WishlistProductRepository, + private val productRepository: ProductRepository, +) { + fun addWishlist( + request: AddWishlistRequest, + currentUser: CurrentUser, + ): AddWishlistResponse { + if (productRepository.existsById(request.productId).not()) { + throw ProductNotFoundException(request.productId) + } + + val wishlistProduct = + WishlistProduct(productId = request.productId, userId = currentUser.id).let { + wishlistProductRepository.findByIdOrNull(it.id) + ?: wishlistProductRepository.save(it) + } + + return AddWishlistResponse( + wishlistProduct, + ) + } +} diff --git a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt new file mode 100644 index 0000000..1c6f667 --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt @@ -0,0 +1,28 @@ +package shopping.wishlist.controller + +import jakarta.validation.Valid +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 shopping.common.api.ApiResponse +import shopping.common.domain.CurrentUser +import shopping.wishlist.application.AddWishlistRequest +import shopping.wishlist.application.AddWishlistResponse +import shopping.wishlist.application.WishlistService + +@RestController +@RequestMapping("/wishlists") +class WishlistController( + private val wishlistService: WishlistService, +) { + @PostMapping + fun addWishlist( + @Valid @RequestBody request: AddWishlistRequest, + currentUser: CurrentUser, + ): ApiResponse { + val response = wishlistService.addWishlist(request = request, currentUser = currentUser) + + return ApiResponse.success(response) + } +} diff --git a/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt b/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt new file mode 100644 index 0000000..7d50cc7 --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt @@ -0,0 +1,36 @@ +package shopping.wishlist.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.persistence.EmbeddedId +import jakarta.persistence.Entity +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.io.Serializable +import java.time.LocalDateTime + +@Embeddable +class WishlistProductId( + @Column + val productId: Long, + @Column + val userId: Long, +) : Serializable + +@Entity +class WishlistProduct( + productId: Long, + userId: Long, +) { + @EmbeddedId + val id = WishlistProductId(productId, userId) + + @CreationTimestamp + @Column + val createdAt: LocalDateTime? = null + + @UpdateTimestamp + @Column + var updatedAt: LocalDateTime? = null + protected set +} diff --git a/src/main/kotlin/shopping/wishlist/domain/WishlistProductRepository.kt b/src/main/kotlin/shopping/wishlist/domain/WishlistProductRepository.kt new file mode 100644 index 0000000..2ac50d9 --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/domain/WishlistProductRepository.kt @@ -0,0 +1,5 @@ +package shopping.wishlist.domain + +import org.springframework.data.jpa.repository.JpaRepository + +interface WishlistProductRepository : JpaRepository From e46f60a41e76009a57ad27811e28bb78f205b865 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sat, 20 Jul 2024 01:01:01 +0900 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20=EC=9C=84=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=83=81=ED=92=88=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/main/java/shopping/Application.java | 11 --- src/main/kotlin/shopping/Application.kt | 11 +++ .../application/WishlistProductDto.kt | 11 +++ .../application/WishlistQueryService.kt | 68 +++++++++++++++++++ .../wishlist/application/WishlistService.kt | 3 +- .../wishlist/controller/WishlistController.kt | 17 +++++ .../wishlist/domain/WishlistProduct.kt | 16 +++-- .../application/WishlistQueryServiceTest.kt | 5 ++ 9 files changed, 126 insertions(+), 18 deletions(-) delete mode 100644 src/main/java/shopping/Application.java create mode 100644 src/main/kotlin/shopping/Application.kt create mode 100644 src/main/kotlin/shopping/wishlist/application/WishlistProductDto.kt create mode 100644 src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt create mode 100644 src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt diff --git a/README.md b/README.md index fb7b447..55877d6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - [x] 로그인시 토큰 발급 api - [ ] 엑세스 토큰 재발급 api ### 위시리스트 -- [ ] 위시리스트 상품목록 조회 api +- [x] 위시리스트 상품목록 조회 api - [x] 위시리스트 상품 추가 api - [x] 중복 추가 방지 - [x] 상품 존재 여부 검사 diff --git a/src/main/java/shopping/Application.java b/src/main/java/shopping/Application.java deleted file mode 100644 index 9ab85bd..0000000 --- a/src/main/java/shopping/Application.java +++ /dev/null @@ -1,11 +0,0 @@ -package shopping; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } -} diff --git a/src/main/kotlin/shopping/Application.kt b/src/main/kotlin/shopping/Application.kt new file mode 100644 index 0000000..35497ee --- /dev/null +++ b/src/main/kotlin/shopping/Application.kt @@ -0,0 +1,11 @@ +package shopping + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class Application + +fun main(args: Array) { + SpringApplication.run(Application::class.java, *args) +} diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistProductDto.kt b/src/main/kotlin/shopping/wishlist/application/WishlistProductDto.kt new file mode 100644 index 0000000..ee35d2a --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/application/WishlistProductDto.kt @@ -0,0 +1,11 @@ +package shopping.wishlist.application + +import java.time.LocalDateTime + +data class WishlistProductDto( + val productId: Long, + val productName: String, + val productPrice: Int, + val productImageUrl: String, + val createdAt: LocalDateTime?, +) diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt b/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt new file mode 100644 index 0000000..2518ab5 --- /dev/null +++ b/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt @@ -0,0 +1,68 @@ +package shopping.wishlist.application + +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import shopping.common.domain.CurrentUser + +@Service +class WishlistQueryService( + @PersistenceContext + private val entityManager: EntityManager, +) { + fun findAllByUserId( + currentUser: CurrentUser, + pageable: Pageable, + ): Page { + val jpql = + """ + select new shopping.wishlist.application.WishlistProductDto( + wp.id.productId, + p.name, + p.price, + p.imageUrl, + wp.createdAt + ) + $fromClause + ${whereClause(currentUser.id)} + order by wp.createdAt desc + """.trimIndent() + + val query = + entityManager + .createQuery(jpql, WishlistProductDto::class.java) + .setFirstResult(pageable.offset.toInt()) + .setMaxResults(pageable.pageSize) + + val content = query.resultList + val totalSize = getTotalCount(currentUser.id) + + return PageImpl(content, pageable, totalSize) + } + + fun getTotalCount(userId: Long): Long { + val jpql = + """ + select count(wp) + $fromClause + ${whereClause(userId)} + """.trimIndent() + + val query = entityManager.createQuery(jpql, Long::class.java) + return query.singleResult + } + + private val fromClause = + """ + from WishlistProduct wp + join Product p on wp.id.productId = p.id + """.trimIndent() + + private fun whereClause(userId: Long) = + """ + where wp.id.userId = $userId + """.trimIndent() +} diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt index 69962d0..4f0a349 100644 --- a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt +++ b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt @@ -23,8 +23,7 @@ class WishlistService( val wishlistProduct = WishlistProduct(productId = request.productId, userId = currentUser.id).let { - wishlistProductRepository.findByIdOrNull(it.id) - ?: wishlistProductRepository.save(it) + wishlistProductRepository.findByIdOrNull(it.id) ?: wishlistProductRepository.save(it) } return AddWishlistResponse( diff --git a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt index 1c6f667..4ab61a6 100644 --- a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt @@ -1,6 +1,10 @@ package shopping.wishlist.controller import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -9,12 +13,15 @@ import shopping.common.api.ApiResponse import shopping.common.domain.CurrentUser import shopping.wishlist.application.AddWishlistRequest import shopping.wishlist.application.AddWishlistResponse +import shopping.wishlist.application.WishlistProductDto +import shopping.wishlist.application.WishlistQueryService import shopping.wishlist.application.WishlistService @RestController @RequestMapping("/wishlists") class WishlistController( private val wishlistService: WishlistService, + private val wishlistQueryService: WishlistQueryService, ) { @PostMapping fun addWishlist( @@ -25,4 +32,14 @@ class WishlistController( return ApiResponse.success(response) } + + @GetMapping + fun getWishlists( + currentUser: CurrentUser, + @PageableDefault pageable: Pageable, + ): ApiResponse> { + val response = wishlistQueryService.findAllByUserId(currentUser = currentUser, pageable = pageable) + + return ApiResponse.success(response) + } } diff --git a/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt b/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt index 7d50cc7..a03d5b8 100644 --- a/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt +++ b/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt @@ -4,26 +4,34 @@ import jakarta.persistence.Column import jakarta.persistence.Embeddable import jakarta.persistence.EmbeddedId import jakarta.persistence.Entity +import jakarta.persistence.Index +import jakarta.persistence.Table import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp import java.io.Serializable import java.time.LocalDateTime @Embeddable -class WishlistProductId( - @Column - val productId: Long, +data class WishlistProductId( @Column val userId: Long, + @Column + val productId: Long, ) : Serializable @Entity +@Table( + name = "wishlist", + indexes = [ + Index(name = "ix_user_id_product_id", columnList = "userId, productId"), + ], +) class WishlistProduct( productId: Long, userId: Long, ) { @EmbeddedId - val id = WishlistProductId(productId, userId) + val id: WishlistProductId = WishlistProductId(userId, productId) @CreationTimestamp @Column diff --git a/src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt b/src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt new file mode 100644 index 0000000..16c820b --- /dev/null +++ b/src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt @@ -0,0 +1,5 @@ +package shopping.wishlist.application + +import org.junit.jupiter.api.Assertions.* + +class WishlistQueryServiceTest From ad5726fc23249b8e06986cb9b2245016a365893a Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sat, 20 Jul 2024 01:33:22 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat(wishlist):=20=EC=9C=84=EC=8B=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- .../wishlist/application/WishlistService.kt | 11 +++++++++++ .../wishlist/controller/WishlistController.kt | 13 +++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55877d6..e3cf9c7 100644 --- a/README.md +++ b/README.md @@ -23,5 +23,4 @@ - [x] 위시리스트 상품 추가 api - [x] 중복 추가 방지 - [x] 상품 존재 여부 검사 -- [ ] 위시리스트 상품 삭제 api - - [ ] 상품 존재 여부 검사 +- [x] 위시리스트 상품 삭제 api diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt index 4f0a349..54a264a 100644 --- a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt +++ b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt @@ -6,6 +6,7 @@ import shopping.common.domain.CurrentUser import shopping.product.application.ProductNotFoundException import shopping.product.domain.ProductRepository import shopping.wishlist.domain.WishlistProduct +import shopping.wishlist.domain.WishlistProductId import shopping.wishlist.domain.WishlistProductRepository @Service @@ -30,4 +31,14 @@ class WishlistService( wishlistProduct, ) } + + fun deleteWishlist( + productId: Long, + currentUser: CurrentUser, + ) { + val id = WishlistProductId(productId, currentUser.id) + val wishlistProduct = wishlistProductRepository.findByIdOrNull(id) ?: return + + wishlistProductRepository.delete(wishlistProduct) + } } diff --git a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt index 4ab61a6..3ca21d6 100644 --- a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt @@ -4,7 +4,10 @@ import jakarta.validation.Valid import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -42,4 +45,14 @@ class WishlistController( return ApiResponse.success(response) } + + @DeleteMapping("/{productId}") + fun deleteWishlist( + currentUser: CurrentUser, + @PathVariable("productId") productId: Long, + ): ResponseEntity> { + wishlistService.deleteWishlist(productId = productId, currentUser = currentUser) + + return ResponseEntity.noContent().build() + } } From 9b4c97c9f86092f5b08954c2e77f3712f873ad8a Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Thu, 25 Jul 2024 00:11:27 +0900 Subject: [PATCH 16/29] =?UTF-8?q?remove(product):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shopping/product/domain/BadWordsCheckDomainService.kt | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt diff --git a/src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt b/src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt deleted file mode 100644 index e570290..0000000 --- a/src/main/kotlin/shopping/product/domain/BadWordsCheckDomainService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package shopping.product.domain - -import org.springframework.stereotype.Service - -@Service -class BadWordsCheckDomainService { - fun isBadWords(text: String): Boolean = false -} From 9a9b8ce5c6c415ffea5b75fddd211af94789e634 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Thu, 25 Jul 2024 02:48:18 +0900 Subject: [PATCH 17/29] =?UTF-8?q?feat(product):=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=EB=8F=84=20=EC=9E=85=EB=A0=A5=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ src/main/kotlin/shopping/product/domain/Product.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4e417c6..4d3ad4a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,8 @@ dependencies { runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("io.rest-assured:rest-assured:5.5.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/kotlin/shopping/product/domain/Product.kt b/src/main/kotlin/shopping/product/domain/Product.kt index 12c024c..9de7663 100644 --- a/src/main/kotlin/shopping/product/domain/Product.kt +++ b/src/main/kotlin/shopping/product/domain/Product.kt @@ -8,7 +8,7 @@ import shopping.common.domain.BaseEntity import java.time.LocalDateTime const val MAX_PRODUCT_NAME_LENGTH: Int = 15 -const val PRODUCT_NAME_PATTERN: String = "^[a-zA-Z0-9 ()\\[\\]+\\-&/_]*$" +const val PRODUCT_NAME_PATTERN: String = "^[ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9 ()\\[\\]\\+\\-&/_]*$" @SQLDelete(sql = "update product set deleted_at = CURRENT_TIMESTAMP where id = ?") @SQLRestriction(value = "deleted_at is null") From 4b6d30d34e20f4a99a4e5d6655385290c0cc713d Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Thu, 25 Jul 2024 03:02:07 +0900 Subject: [PATCH 18/29] =?UTF-8?q?chore(common):=20400=EB=8C=80=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EB=93=A4=20=EB=A1=9C=EA=B7=B8=EB=A0=88=EB=B2=A8=20war?= =?UTF-8?q?n=20=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt index b95c6b4..de5afa9 100644 --- a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt +++ b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt @@ -22,7 +22,7 @@ class GlobalErrorHandler { @ExceptionHandler(LoginFailedException::class) fun loginFailedExceptionHandler(e: LoginFailedException): ResponseEntity> { - logger.error("LoginFailedException: {}", e.message, e) + logger.warn("LoginFailedException: {}", e.message, e) val errorMessage = ErrorMessage( errorCode = ErrorCode.LOGIN_FAILED, @@ -36,7 +36,7 @@ class GlobalErrorHandler { @ExceptionHandler(HttpMessageNotReadableException::class) fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException): ResponseEntity> { - logger.error("HttpMessageNotReadableException: {}", e.message, e) + logger.warn("HttpMessageNotReadableException: {}", e.message, e) val data = when (val causeException = e.cause) { is MismatchedInputException -> causeException.path[0].toData() @@ -57,7 +57,7 @@ class GlobalErrorHandler { @ExceptionHandler(MethodArgumentNotValidException::class) fun requestValidationExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity> { - logger.error("MethodArgumentNotValidException: {}", e.message, e) + logger.warn("MethodArgumentNotValidException: {}", e.message, e) val errorMessage = ErrorMessage( errorCode = ErrorCode.VALIDATION_ERROR, From c3b223a4b67a80f7875f1b1407be6050801c55f0 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Thu, 25 Jul 2024 03:02:41 +0900 Subject: [PATCH 19/29] =?UTF-8?q?chore(common):=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=EC=97=90=EB=9F=AC=20400?= =?UTF-8?q?=EB=8C=80=20=EC=9D=91=EB=8B=B5=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추후에 에러마다 변경가능하게 하기 --- src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt index de5afa9..1942d13 100644 --- a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt +++ b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt @@ -81,7 +81,7 @@ class GlobalErrorHandler { ) return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(errorMessage)) } From f4b6c6d9ccb3c9dea60be7b39f9464e292d2e75e Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Thu, 25 Jul 2024 03:03:28 +0900 Subject: [PATCH 20/29] =?UTF-8?q?test(product):=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/application/ProductCreateDtos.kt | 7 +- src/test/kotlin/shopping/E2ETest.kt | 24 +++++ .../controller/ProductControllerTest.kt | 92 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/shopping/E2ETest.kt create mode 100644 src/test/kotlin/shopping/product/controller/ProductControllerTest.kt diff --git a/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt b/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt index bc044bd..efed9bc 100644 --- a/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt +++ b/src/main/kotlin/shopping/product/application/ProductCreateDtos.kt @@ -8,9 +8,12 @@ import shopping.product.domain.MAX_PRODUCT_NAME_LENGTH import shopping.product.domain.PRODUCT_NAME_PATTERN import shopping.product.domain.Product +const val INVALID_NAME_LENGTH_MESSAGE = "상품명은 1자 이상 ${MAX_PRODUCT_NAME_LENGTH}자 이하여야 합니다." +const val INVALID_NAME_PATTERN_MESSAGE = "허용되지 않은 특수문자가 포함되어있습니다." + data class CreateProductRequest( - @field:Size(min = 1, max = MAX_PRODUCT_NAME_LENGTH, message = "상품명은 1자 이상 ${MAX_PRODUCT_NAME_LENGTH}자 이하여야 합니다.") - @field:Pattern(regexp = PRODUCT_NAME_PATTERN, message = "허용되지 않은 특수문자가 포함되어있습니다.") + @field:Size(min = 1, max = MAX_PRODUCT_NAME_LENGTH, message = INVALID_NAME_LENGTH_MESSAGE) + @field:Pattern(regexp = PRODUCT_NAME_PATTERN, message = INVALID_NAME_PATTERN_MESSAGE) val name: String, @field:Min(0) val price: Int, diff --git a/src/test/kotlin/shopping/E2ETest.kt b/src/test/kotlin/shopping/E2ETest.kt new file mode 100644 index 0000000..269121c --- /dev/null +++ b/src/test/kotlin/shopping/E2ETest.kt @@ -0,0 +1,24 @@ +package shopping + +import io.restassured.RestAssured +import jakarta.transaction.Transactional +import org.junit.jupiter.api.BeforeEach +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestConstructor + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@Transactional +abstract class E2ETest { + @LocalServerPort + protected var port: Int = 0 + + @BeforeEach + fun setup() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + } +} diff --git a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt new file mode 100644 index 0000000..79e36f6 --- /dev/null +++ b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt @@ -0,0 +1,92 @@ +package shopping.product.controller + +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.springframework.boot.test.mock.mockito.MockBean +import shopping.E2ETest +import shopping.product.application.CreateProductRequest +import shopping.product.application.INVALID_NAME_LENGTH_MESSAGE +import shopping.product.application.INVALID_NAME_PATTERN_MESSAGE +import shopping.product.domain.BadWordValidator + +class ProductControllerTest( + @MockBean private val badWordValidator: BadWordValidator, +) : E2ETest() { + @Test + fun `상품 등록 성공`() { + RestAssured + .given() + .contentType(ContentType.JSON) + .body(buildRequest()) + .`when`() + .post("/products") + .then() + .statusCode(200) + } + + @ValueSource(strings = ["", "1234512345123451"]) + @ParameterizedTest(name = "상품명: {0}") + fun `상품이름 길이를 만족해야한다`(productName: String) { + RestAssured + .given() + .contentType(ContentType.JSON) + .body( + buildRequest( + name = productName, + ), + ).`when`() + .post("/products") + .then() + .statusCode(400) + .body("error.data[0].message", equalTo(INVALID_NAME_LENGTH_MESSAGE)) + } + + @Test + fun `허용되지 않는 특수문자는 등록할수 없다`() { + RestAssured + .given() + .contentType(ContentType.JSON) + .body( + buildRequest( + name = "product !", + ), + ).`when`() + .post("/products") + .then() + .statusCode(400) + .body("error.data[0].message", equalTo(INVALID_NAME_PATTERN_MESSAGE)) + } + + @Test + fun `이름에 영문 비속어가 있으면 등록할수 없다`() { + whenever(badWordValidator.isBadWord(any())).thenReturn(true) + RestAssured + .given() + .contentType(ContentType.JSON) + .body(buildRequest()) + .`when`() + .post("/products") + .then() + .statusCode(500) + .log() + .all() + } +} + +private fun buildRequest( + name: String = "[p_1] 상품", + price: Int = 1000, + imageUrl: String = "http://localhost:8080/image1", + stockQuantity: Int = 10, +) = CreateProductRequest( + name = name, + price = price, + imageUrl = imageUrl, + stockQuantity = stockQuantity, +) From c165f39242f10b738a5a36568af3a1320f4f6e82 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Fri, 26 Jul 2024 00:13:56 +0900 Subject: [PATCH 21/29] =?UTF-8?q?chore(common):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=EB=90=9C=EA=B2=83=EB=93=A4=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=AC=B6=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/shopping/common/api/GlobalErrorHandler.kt | 2 +- .../kotlin/shopping/common/{util => auth}/AuthUtil.kt | 2 +- .../shopping/common/{util => auth}/JwtProvider.kt | 2 +- .../shopping/common/auth/LoginFailedException.kt | 10 ++++++++++ .../shopping/common/error/LoginFailedException.kt | 10 ---------- .../kotlin/shopping/user/application/UserService.kt | 2 +- .../kotlin/shopping/user/domain/EncryptedPassword.kt | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) rename src/main/kotlin/shopping/common/{util => auth}/AuthUtil.kt (90%) rename src/main/kotlin/shopping/common/{util => auth}/JwtProvider.kt (97%) create mode 100644 src/main/kotlin/shopping/common/auth/LoginFailedException.kt delete mode 100644 src/main/kotlin/shopping/common/error/LoginFailedException.kt diff --git a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt index 1942d13..fbe05dc 100644 --- a/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt +++ b/src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt @@ -11,10 +11,10 @@ import org.springframework.validation.FieldError import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +import shopping.common.auth.LoginFailedException import shopping.common.error.ApiException import shopping.common.error.ErrorCode import shopping.common.error.ErrorMessage -import shopping.common.error.LoginFailedException @RestControllerAdvice class GlobalErrorHandler { diff --git a/src/main/kotlin/shopping/common/util/AuthUtil.kt b/src/main/kotlin/shopping/common/auth/AuthUtil.kt similarity index 90% rename from src/main/kotlin/shopping/common/util/AuthUtil.kt rename to src/main/kotlin/shopping/common/auth/AuthUtil.kt index bd44624..8fc4e84 100644 --- a/src/main/kotlin/shopping/common/util/AuthUtil.kt +++ b/src/main/kotlin/shopping/common/auth/AuthUtil.kt @@ -1,4 +1,4 @@ -package shopping.common.util +package shopping.common.auth import java.security.MessageDigest diff --git a/src/main/kotlin/shopping/common/util/JwtProvider.kt b/src/main/kotlin/shopping/common/auth/JwtProvider.kt similarity index 97% rename from src/main/kotlin/shopping/common/util/JwtProvider.kt rename to src/main/kotlin/shopping/common/auth/JwtProvider.kt index 9a54378..0eb342a 100644 --- a/src/main/kotlin/shopping/common/util/JwtProvider.kt +++ b/src/main/kotlin/shopping/common/auth/JwtProvider.kt @@ -1,4 +1,4 @@ -package shopping.common.util +package shopping.common.auth import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys diff --git a/src/main/kotlin/shopping/common/auth/LoginFailedException.kt b/src/main/kotlin/shopping/common/auth/LoginFailedException.kt new file mode 100644 index 0000000..26586f5 --- /dev/null +++ b/src/main/kotlin/shopping/common/auth/LoginFailedException.kt @@ -0,0 +1,10 @@ +package shopping.common.auth + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +abstract class LoginFailedException( + errorCode: ErrorCode, + message: String, + cause: Throwable? = null, +) : ApiException(errorCode, message, cause) diff --git a/src/main/kotlin/shopping/common/error/LoginFailedException.kt b/src/main/kotlin/shopping/common/error/LoginFailedException.kt deleted file mode 100644 index f997f6f..0000000 --- a/src/main/kotlin/shopping/common/error/LoginFailedException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package shopping.common.error - -class LoginFailedException( - message: String, - cause: Throwable? = null, -) : ApiException( - errorCode = ErrorCode.LOGIN_FAILED, - message = message, - cause = cause, - ) diff --git a/src/main/kotlin/shopping/user/application/UserService.kt b/src/main/kotlin/shopping/user/application/UserService.kt index 2e810ca..3d654ec 100644 --- a/src/main/kotlin/shopping/user/application/UserService.kt +++ b/src/main/kotlin/shopping/user/application/UserService.kt @@ -2,7 +2,7 @@ package shopping.user.application import jakarta.transaction.Transactional import org.springframework.stereotype.Service -import shopping.common.util.JwtProvider +import shopping.common.auth.JwtProvider import shopping.user.domain.EncryptedPassword import shopping.user.domain.PasswordMismatchException import shopping.user.domain.User diff --git a/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt b/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt index cdb92fd..ca6e4c1 100644 --- a/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt +++ b/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt @@ -1,6 +1,6 @@ package shopping.user.domain -import shopping.common.util.AuthUtil +import shopping.common.auth.AuthUtil const val MIN_PASSWORD_LENGTH = 10 const val MAX_PASSWORD_LENGTH = 20 From e8331173d7fcc9428b39646383ba2a76a7560eb3 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 00:59:59 +0900 Subject: [PATCH 22/29] =?UTF-8?q?feat(common):=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=EB=93=A4=20=EB=8D=94=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EB=88=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/api/CurrentUserArgumentResolver.kt | 14 +++++++++----- .../shopping/common/auth/InvalidTokenException.kt | 11 +++++++++++ .../shopping/common/auth/TokenExpiredException.kt | 9 +++++++++ .../shopping/common/auth/TokenMissingException.kt | 9 +++++++++ src/main/kotlin/shopping/common/error/ErrorCode.kt | 3 +++ 5 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/shopping/common/auth/InvalidTokenException.kt create mode 100644 src/main/kotlin/shopping/common/auth/TokenExpiredException.kt create mode 100644 src/main/kotlin/shopping/common/auth/TokenMissingException.kt diff --git a/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt b/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt index d43ab3f..a907d15 100644 --- a/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt +++ b/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt @@ -1,5 +1,6 @@ package shopping.common.api +import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.JwtException import org.springframework.core.MethodParameter import org.springframework.http.HttpHeaders.AUTHORIZATION @@ -8,9 +9,11 @@ import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer +import shopping.common.auth.InvalidTokenException +import shopping.common.auth.JwtProvider +import shopping.common.auth.TokenExpiredException +import shopping.common.auth.TokenMissingException import shopping.common.domain.CurrentUser -import shopping.common.error.LoginFailedException -import shopping.common.util.JwtProvider import shopping.user.application.UserNotFoundException import shopping.user.domain.UserRepository @@ -40,13 +43,14 @@ class CurrentUserArgumentResolver( jwtProvider.getSubject(token) } catch (e: Exception) { when (e) { - is JwtException, is IllegalArgumentException -> throw LoginFailedException("인증정보가 유효하지 않습니다.", e) + is ExpiredJwtException -> throw TokenExpiredException() + is JwtException, is IllegalArgumentException -> throw InvalidTokenException(e) else -> throw e } } private fun getToken(webRequest: NativeWebRequest): String { - val token = webRequest.getHeader(AUTHORIZATION) ?: throw LoginFailedException("인증정보가 없습니다.") - return token.split(" ").getOrNull(1) ?: throw LoginFailedException("인증정보가 유효하지 않습니다.") + val token = webRequest.getHeader(AUTHORIZATION) ?: throw TokenMissingException() + return token.split(" ").getOrNull(1) ?: throw InvalidTokenException() } } diff --git a/src/main/kotlin/shopping/common/auth/InvalidTokenException.kt b/src/main/kotlin/shopping/common/auth/InvalidTokenException.kt new file mode 100644 index 0000000..c11b64c --- /dev/null +++ b/src/main/kotlin/shopping/common/auth/InvalidTokenException.kt @@ -0,0 +1,11 @@ +package shopping.common.auth + +import shopping.common.error.ErrorCode + +class InvalidTokenException( + cause: Throwable? = null, +) : LoginFailedException( + errorCode = ErrorCode.INVALID_TOKEN, + message = "토큰이 유효하지 않습니다.", + cause = cause, + ) diff --git a/src/main/kotlin/shopping/common/auth/TokenExpiredException.kt b/src/main/kotlin/shopping/common/auth/TokenExpiredException.kt new file mode 100644 index 0000000..17625d5 --- /dev/null +++ b/src/main/kotlin/shopping/common/auth/TokenExpiredException.kt @@ -0,0 +1,9 @@ +package shopping.common.auth + +import shopping.common.error.ErrorCode + +class TokenExpiredException : + LoginFailedException( + errorCode = ErrorCode.TOKEN_EXPIRED, + message = "토큰이 만료되었습니다.", + ) diff --git a/src/main/kotlin/shopping/common/auth/TokenMissingException.kt b/src/main/kotlin/shopping/common/auth/TokenMissingException.kt new file mode 100644 index 0000000..261e239 --- /dev/null +++ b/src/main/kotlin/shopping/common/auth/TokenMissingException.kt @@ -0,0 +1,9 @@ +package shopping.common.auth + +import shopping.common.error.ErrorCode + +class TokenMissingException : + LoginFailedException( + errorCode = ErrorCode.TOKEN_NOT_FOUND, + message = "인증정보가 없습니다.", + ) diff --git a/src/main/kotlin/shopping/common/error/ErrorCode.kt b/src/main/kotlin/shopping/common/error/ErrorCode.kt index 7589b02..3cc056b 100644 --- a/src/main/kotlin/shopping/common/error/ErrorCode.kt +++ b/src/main/kotlin/shopping/common/error/ErrorCode.kt @@ -6,6 +6,9 @@ enum class ErrorCode( VALIDATION_ERROR("C0001"), UNKNOWN_ERROR("C0002"), LOGIN_FAILED("C0003"), + TOKEN_EXPIRED("C0004"), + INVALID_TOKEN("C0005"), + TOKEN_NOT_FOUND("C0006"), PRODUCT_NOT_FOUND("P0000"), PRODUCT_NAME_CONTAIN_BAD_WORD("P0001"), From eff7b5832bd3ca6a0eda1d1c5012a184b4163d71 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 01:01:29 +0900 Subject: [PATCH 23/29] =?UTF-8?q?refactor(user):=20user=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=99=80=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20member=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...er.kt => CurrentMemberArgumentResolver.kt} | 18 ++++---- .../{CurrentUser.kt => CurrentMember.kt} | 2 +- src/main/kotlin/shopping/config/WebConfig.kt | 6 +-- .../application/MemberLoginDtos.kt} | 10 ++-- .../application/MemberNotFoundException.kt | 17 +++++++ .../application/MemberRegistDtos.kt} | 10 ++-- .../member/application/MemberService.kt | 46 +++++++++++++++++++ .../member/controller/MemberController.kt | 37 +++++++++++++++ .../domain/EncryptedPassword.kt | 2 +- .../User.kt => member/domain/Member.kt} | 6 +-- .../member/domain/MemberRepository.kt | 9 ++++ .../domain/PasswordMismatchException.kt | 2 +- .../domain/UserAlreadyRegisteredException.kt | 2 +- .../user/application/UserNotFoundException.kt | 17 ------- .../shopping/user/application/UserService.kt | 46 ------------------- .../user/controller/UserController.kt | 37 --------------- .../shopping/user/domain/UserRepository.kt | 9 ---- .../application/WishlistQueryService.kt | 16 +++---- .../wishlist/application/WishlistService.kt | 10 ++-- .../wishlist/controller/WishlistController.kt | 14 +++--- .../wishlist/domain/WishlistProduct.kt | 9 ++-- 21 files changed, 161 insertions(+), 164 deletions(-) rename src/main/kotlin/shopping/common/api/{CurrentUserArgumentResolver.kt => CurrentMemberArgumentResolver.kt} (79%) rename src/main/kotlin/shopping/common/domain/{CurrentUser.kt => CurrentMember.kt} (74%) rename src/main/kotlin/shopping/{user/application/UserLoginDtos.kt => member/application/MemberLoginDtos.kt} (56%) create mode 100644 src/main/kotlin/shopping/member/application/MemberNotFoundException.kt rename src/main/kotlin/shopping/{user/application/UserRegistDtos.kt => member/application/MemberRegistDtos.kt} (63%) create mode 100644 src/main/kotlin/shopping/member/application/MemberService.kt create mode 100644 src/main/kotlin/shopping/member/controller/MemberController.kt rename src/main/kotlin/shopping/{user => member}/domain/EncryptedPassword.kt (94%) rename src/main/kotlin/shopping/{user/domain/User.kt => member/domain/Member.kt} (76%) create mode 100644 src/main/kotlin/shopping/member/domain/MemberRepository.kt rename src/main/kotlin/shopping/{user => member}/domain/PasswordMismatchException.kt (89%) rename src/main/kotlin/shopping/{user => member}/domain/UserAlreadyRegisteredException.kt (90%) delete mode 100644 src/main/kotlin/shopping/user/application/UserNotFoundException.kt delete mode 100644 src/main/kotlin/shopping/user/application/UserService.kt delete mode 100644 src/main/kotlin/shopping/user/controller/UserController.kt delete mode 100644 src/main/kotlin/shopping/user/domain/UserRepository.kt diff --git a/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt b/src/main/kotlin/shopping/common/api/CurrentMemberArgumentResolver.kt similarity index 79% rename from src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt rename to src/main/kotlin/shopping/common/api/CurrentMemberArgumentResolver.kt index a907d15..cdab863 100644 --- a/src/main/kotlin/shopping/common/api/CurrentUserArgumentResolver.kt +++ b/src/main/kotlin/shopping/common/api/CurrentMemberArgumentResolver.kt @@ -13,16 +13,16 @@ import shopping.common.auth.InvalidTokenException import shopping.common.auth.JwtProvider import shopping.common.auth.TokenExpiredException import shopping.common.auth.TokenMissingException -import shopping.common.domain.CurrentUser -import shopping.user.application.UserNotFoundException -import shopping.user.domain.UserRepository +import shopping.common.domain.CurrentMember +import shopping.member.application.MemberNotFoundException +import shopping.member.domain.MemberRepository @Component -class CurrentUserArgumentResolver( +class CurrentMemberArgumentResolver( private val jwtProvider: JwtProvider, - private val userRepository: UserRepository, + private val memberRepository: MemberRepository, ) : HandlerMethodArgumentResolver { - override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == CurrentUser::class.java + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == CurrentMember::class.java override fun resolveArgument( parameter: MethodParameter, @@ -33,9 +33,9 @@ class CurrentUserArgumentResolver( val token = getToken(webRequest) val email = getEmail(token) - userRepository.findByEmail(email)?.let { - return CurrentUser(it.id, it.email) - } ?: throw UserNotFoundException.fromEmail(email) + memberRepository.findByEmail(email)?.let { + return CurrentMember(it.id, it.email) + } ?: throw MemberNotFoundException.fromEmail(email) } private fun getEmail(token: String) = diff --git a/src/main/kotlin/shopping/common/domain/CurrentUser.kt b/src/main/kotlin/shopping/common/domain/CurrentMember.kt similarity index 74% rename from src/main/kotlin/shopping/common/domain/CurrentUser.kt rename to src/main/kotlin/shopping/common/domain/CurrentMember.kt index 8d687a0..22485cd 100644 --- a/src/main/kotlin/shopping/common/domain/CurrentUser.kt +++ b/src/main/kotlin/shopping/common/domain/CurrentMember.kt @@ -1,6 +1,6 @@ package shopping.common.domain -data class CurrentUser( +data class CurrentMember( val id: Long, val email: String, ) diff --git a/src/main/kotlin/shopping/config/WebConfig.kt b/src/main/kotlin/shopping/config/WebConfig.kt index b339a2f..1973f34 100644 --- a/src/main/kotlin/shopping/config/WebConfig.kt +++ b/src/main/kotlin/shopping/config/WebConfig.kt @@ -3,13 +3,13 @@ package shopping.config import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.WebMvcConfigurer -import shopping.common.api.CurrentUserArgumentResolver +import shopping.common.api.CurrentMemberArgumentResolver @Configuration class WebConfig( - private val currentUserArgumentResolver: CurrentUserArgumentResolver, + private val currentMemberArgumentResolver: CurrentMemberArgumentResolver, ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(currentUserArgumentResolver) + resolvers.add(currentMemberArgumentResolver) } } diff --git a/src/main/kotlin/shopping/user/application/UserLoginDtos.kt b/src/main/kotlin/shopping/member/application/MemberLoginDtos.kt similarity index 56% rename from src/main/kotlin/shopping/user/application/UserLoginDtos.kt rename to src/main/kotlin/shopping/member/application/MemberLoginDtos.kt index 808d803..47dde32 100644 --- a/src/main/kotlin/shopping/user/application/UserLoginDtos.kt +++ b/src/main/kotlin/shopping/member/application/MemberLoginDtos.kt @@ -1,17 +1,17 @@ -package shopping.user.application +package shopping.member.application import jakarta.validation.constraints.Email import jakarta.validation.constraints.Size -import shopping.user.domain.MAX_PASSWORD_LENGTH -import shopping.user.domain.MIN_PASSWORD_LENGTH +import shopping.member.domain.MAX_PASSWORD_LENGTH +import shopping.member.domain.MIN_PASSWORD_LENGTH -data class UserLoginRequest( +data class MemberLoginRequest( @field:Email val email: String, @field:Size(min = MIN_PASSWORD_LENGTH, max = MAX_PASSWORD_LENGTH) val password: String, ) -data class UserLoginResponse( +data class MemberLoginResponse( val accessToken: String, ) diff --git a/src/main/kotlin/shopping/member/application/MemberNotFoundException.kt b/src/main/kotlin/shopping/member/application/MemberNotFoundException.kt new file mode 100644 index 0000000..5431741 --- /dev/null +++ b/src/main/kotlin/shopping/member/application/MemberNotFoundException.kt @@ -0,0 +1,17 @@ +package shopping.member.application + +import shopping.common.error.ApiException +import shopping.common.error.ErrorCode + +class MemberNotFoundException private constructor( + message: String, +) : ApiException( + errorCode = ErrorCode.USER_NOT_FOUND, + message = message, + ) { + companion object { + fun fromEmail(email: String): MemberNotFoundException = MemberNotFoundException("email: $email 사용자를 찾을 수 없습니다.") + + fun fromId(id: Long): MemberNotFoundException = MemberNotFoundException("id: $id 사용자를 찾을 수 없습니다.") + } +} diff --git a/src/main/kotlin/shopping/user/application/UserRegistDtos.kt b/src/main/kotlin/shopping/member/application/MemberRegistDtos.kt similarity index 63% rename from src/main/kotlin/shopping/user/application/UserRegistDtos.kt rename to src/main/kotlin/shopping/member/application/MemberRegistDtos.kt index b104e5a..45126fd 100644 --- a/src/main/kotlin/shopping/user/application/UserRegistDtos.kt +++ b/src/main/kotlin/shopping/member/application/MemberRegistDtos.kt @@ -1,12 +1,12 @@ -package shopping.user.application +package shopping.member.application import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size -import shopping.user.domain.MAX_PASSWORD_LENGTH -import shopping.user.domain.MIN_PASSWORD_LENGTH +import shopping.member.domain.MAX_PASSWORD_LENGTH +import shopping.member.domain.MIN_PASSWORD_LENGTH -data class UserRegistRequest( +data class MemberRegistRequest( @field:Email val email: String, @field:Size(min = MIN_PASSWORD_LENGTH, max = MAX_PASSWORD_LENGTH) @@ -15,6 +15,6 @@ data class UserRegistRequest( val name: String, ) -data class UserRegistResponse( +data class MemberRegistResponse( val accessToken: String, ) diff --git a/src/main/kotlin/shopping/member/application/MemberService.kt b/src/main/kotlin/shopping/member/application/MemberService.kt new file mode 100644 index 0000000..fc53380 --- /dev/null +++ b/src/main/kotlin/shopping/member/application/MemberService.kt @@ -0,0 +1,46 @@ +package shopping.member.application + +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service +import shopping.common.auth.JwtProvider +import shopping.member.domain.EncryptedPassword +import shopping.member.domain.Member +import shopping.member.domain.MemberRepository +import shopping.member.domain.PasswordMismatchException +import shopping.member.domain.UserAlreadyRegisteredException + +@Service +class MemberService( + private val memberRepository: MemberRepository, + private val jwtProvider: JwtProvider, +) { + @Transactional + fun regist(request: MemberRegistRequest): MemberRegistResponse { + if (memberRepository.existsByEmail(request.email)) { + throw UserAlreadyRegisteredException(request.email) + } + + Member( + email = request.email, + password = EncryptedPassword.from(request.password), + ).let { memberRepository.save(it) } + + val jwt = jwtProvider.createToken(request.email) + + return MemberRegistResponse(accessToken = jwt) + } + + fun login(request: MemberLoginRequest): MemberLoginResponse { + val user = + memberRepository.findByEmail(request.email) + ?: throw MemberNotFoundException.fromEmail(request.email) + + if (user.password != EncryptedPassword.from(request.password)) { + throw PasswordMismatchException() + } + + val jwt = jwtProvider.createToken(request.email) + + return MemberLoginResponse(accessToken = jwt) + } +} diff --git a/src/main/kotlin/shopping/member/controller/MemberController.kt b/src/main/kotlin/shopping/member/controller/MemberController.kt new file mode 100644 index 0000000..01c79b6 --- /dev/null +++ b/src/main/kotlin/shopping/member/controller/MemberController.kt @@ -0,0 +1,37 @@ +package shopping.member.controller + +import jakarta.validation.Valid +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 shopping.common.api.ApiResponse +import shopping.member.application.MemberLoginRequest +import shopping.member.application.MemberLoginResponse +import shopping.member.application.MemberRegistRequest +import shopping.member.application.MemberRegistResponse +import shopping.member.application.MemberService + +@RestController +@RequestMapping("/members") +class MemberController( + private val memberService: MemberService, +) { + @PostMapping("/regist") + fun regist( + @Valid @RequestBody request: MemberRegistRequest, + ): ApiResponse { + val response = memberService.regist(request) + + return ApiResponse.success(response) + } + + @PostMapping("/login") + fun login( + @Valid @RequestBody request: MemberLoginRequest, + ): ApiResponse { + val response = memberService.login(request) + + return ApiResponse.success(response) + } +} diff --git a/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt b/src/main/kotlin/shopping/member/domain/EncryptedPassword.kt similarity index 94% rename from src/main/kotlin/shopping/user/domain/EncryptedPassword.kt rename to src/main/kotlin/shopping/member/domain/EncryptedPassword.kt index ca6e4c1..3fe9cd9 100644 --- a/src/main/kotlin/shopping/user/domain/EncryptedPassword.kt +++ b/src/main/kotlin/shopping/member/domain/EncryptedPassword.kt @@ -1,4 +1,4 @@ -package shopping.user.domain +package shopping.member.domain import shopping.common.auth.AuthUtil diff --git a/src/main/kotlin/shopping/user/domain/User.kt b/src/main/kotlin/shopping/member/domain/Member.kt similarity index 76% rename from src/main/kotlin/shopping/user/domain/User.kt rename to src/main/kotlin/shopping/member/domain/Member.kt index 45c93a5..7f86253 100644 --- a/src/main/kotlin/shopping/user/domain/User.kt +++ b/src/main/kotlin/shopping/member/domain/Member.kt @@ -1,13 +1,11 @@ -package shopping.user.domain +package shopping.member.domain import jakarta.persistence.Column import jakarta.persistence.Entity -import jakarta.persistence.Table import shopping.common.domain.BaseEntity @Entity -@Table(name = "users") -class User( +class Member( email: String, password: EncryptedPassword, ) : BaseEntity() { diff --git a/src/main/kotlin/shopping/member/domain/MemberRepository.kt b/src/main/kotlin/shopping/member/domain/MemberRepository.kt new file mode 100644 index 0000000..99120ed --- /dev/null +++ b/src/main/kotlin/shopping/member/domain/MemberRepository.kt @@ -0,0 +1,9 @@ +package shopping.member.domain + +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberRepository : JpaRepository { + fun existsByEmail(email: String): Boolean + + fun findByEmail(email: String): Member? +} diff --git a/src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt b/src/main/kotlin/shopping/member/domain/PasswordMismatchException.kt similarity index 89% rename from src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt rename to src/main/kotlin/shopping/member/domain/PasswordMismatchException.kt index 5366eba..9cf0a54 100644 --- a/src/main/kotlin/shopping/user/domain/PasswordMismatchException.kt +++ b/src/main/kotlin/shopping/member/domain/PasswordMismatchException.kt @@ -1,4 +1,4 @@ -package shopping.user.domain +package shopping.member.domain import shopping.common.error.ApiException import shopping.common.error.ErrorCode diff --git a/src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt b/src/main/kotlin/shopping/member/domain/UserAlreadyRegisteredException.kt similarity index 90% rename from src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt rename to src/main/kotlin/shopping/member/domain/UserAlreadyRegisteredException.kt index a2de862..a3e18bc 100644 --- a/src/main/kotlin/shopping/user/domain/UserAlreadyRegisteredException.kt +++ b/src/main/kotlin/shopping/member/domain/UserAlreadyRegisteredException.kt @@ -1,4 +1,4 @@ -package shopping.user.domain +package shopping.member.domain import shopping.common.error.ApiException import shopping.common.error.ErrorCode diff --git a/src/main/kotlin/shopping/user/application/UserNotFoundException.kt b/src/main/kotlin/shopping/user/application/UserNotFoundException.kt deleted file mode 100644 index eb8ab99..0000000 --- a/src/main/kotlin/shopping/user/application/UserNotFoundException.kt +++ /dev/null @@ -1,17 +0,0 @@ -package shopping.user.application - -import shopping.common.error.ApiException -import shopping.common.error.ErrorCode - -class UserNotFoundException private constructor( - message: String, -) : ApiException( - errorCode = ErrorCode.USER_NOT_FOUND, - message = message, - ) { - companion object { - fun fromEmail(email: String): UserNotFoundException = UserNotFoundException("email: $email 사용자를 찾을 수 없습니다.") - - fun fromId(id: Long): UserNotFoundException = UserNotFoundException("id: $id 사용자를 찾을 수 없습니다.") - } -} diff --git a/src/main/kotlin/shopping/user/application/UserService.kt b/src/main/kotlin/shopping/user/application/UserService.kt deleted file mode 100644 index 3d654ec..0000000 --- a/src/main/kotlin/shopping/user/application/UserService.kt +++ /dev/null @@ -1,46 +0,0 @@ -package shopping.user.application - -import jakarta.transaction.Transactional -import org.springframework.stereotype.Service -import shopping.common.auth.JwtProvider -import shopping.user.domain.EncryptedPassword -import shopping.user.domain.PasswordMismatchException -import shopping.user.domain.User -import shopping.user.domain.UserAlreadyRegisteredException -import shopping.user.domain.UserRepository - -@Service -class UserService( - private val userRepository: UserRepository, - private val jwtProvider: JwtProvider, -) { - @Transactional - fun regist(request: UserRegistRequest): UserRegistResponse { - if (userRepository.existsByEmail(request.email)) { - throw UserAlreadyRegisteredException(request.email) - } - - User( - email = request.email, - password = EncryptedPassword.from(request.password), - ).let { userRepository.save(it) } - - val jwt = jwtProvider.createToken(request.email) - - return UserRegistResponse(accessToken = jwt) - } - - fun login(request: UserLoginRequest): UserLoginResponse { - val user = - userRepository.findByEmail(request.email) - ?: throw UserNotFoundException.fromEmail(request.email) - - if (user.password != EncryptedPassword.from(request.password)) { - throw PasswordMismatchException() - } - - val jwt = jwtProvider.createToken(request.email) - - return UserLoginResponse(accessToken = jwt) - } -} diff --git a/src/main/kotlin/shopping/user/controller/UserController.kt b/src/main/kotlin/shopping/user/controller/UserController.kt deleted file mode 100644 index cd062a1..0000000 --- a/src/main/kotlin/shopping/user/controller/UserController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package shopping.user.controller - -import jakarta.validation.Valid -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 shopping.common.api.ApiResponse -import shopping.user.application.UserLoginRequest -import shopping.user.application.UserLoginResponse -import shopping.user.application.UserRegistRequest -import shopping.user.application.UserRegistResponse -import shopping.user.application.UserService - -@RestController -@RequestMapping("/users") -class UserController( - private val userService: UserService, -) { - @PostMapping("/regist") - fun regist( - @Valid @RequestBody request: UserRegistRequest, - ): ApiResponse { - val response = userService.regist(request) - - return ApiResponse.success(response) - } - - @PostMapping("/login") - fun login( - @Valid @RequestBody request: UserLoginRequest, - ): ApiResponse { - val response = userService.login(request) - - return ApiResponse.success(response) - } -} diff --git a/src/main/kotlin/shopping/user/domain/UserRepository.kt b/src/main/kotlin/shopping/user/domain/UserRepository.kt deleted file mode 100644 index 7b3d409..0000000 --- a/src/main/kotlin/shopping/user/domain/UserRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package shopping.user.domain - -import org.springframework.data.jpa.repository.JpaRepository - -interface UserRepository : JpaRepository { - fun existsByEmail(email: String): Boolean - - fun findByEmail(email: String): User? -} diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt b/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt index 2518ab5..c62d5a5 100644 --- a/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt +++ b/src/main/kotlin/shopping/wishlist/application/WishlistQueryService.kt @@ -6,7 +6,7 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service -import shopping.common.domain.CurrentUser +import shopping.common.domain.CurrentMember @Service class WishlistQueryService( @@ -14,7 +14,7 @@ class WishlistQueryService( private val entityManager: EntityManager, ) { fun findAllByUserId( - currentUser: CurrentUser, + currentMember: CurrentMember, pageable: Pageable, ): Page { val jpql = @@ -27,7 +27,7 @@ class WishlistQueryService( wp.createdAt ) $fromClause - ${whereClause(currentUser.id)} + ${whereClause(currentMember.id)} order by wp.createdAt desc """.trimIndent() @@ -38,17 +38,17 @@ class WishlistQueryService( .setMaxResults(pageable.pageSize) val content = query.resultList - val totalSize = getTotalCount(currentUser.id) + val totalSize = getTotalCount(currentMember.id) return PageImpl(content, pageable, totalSize) } - fun getTotalCount(userId: Long): Long { + fun getTotalCount(memberId: Long): Long { val jpql = """ select count(wp) $fromClause - ${whereClause(userId)} + ${whereClause(memberId)} """.trimIndent() val query = entityManager.createQuery(jpql, Long::class.java) @@ -61,8 +61,8 @@ class WishlistQueryService( join Product p on wp.id.productId = p.id """.trimIndent() - private fun whereClause(userId: Long) = + private fun whereClause(memberId: Long) = """ - where wp.id.userId = $userId + where wp.id.memberId = $memberId """.trimIndent() } diff --git a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt index 54a264a..74e4f0b 100644 --- a/src/main/kotlin/shopping/wishlist/application/WishlistService.kt +++ b/src/main/kotlin/shopping/wishlist/application/WishlistService.kt @@ -2,7 +2,7 @@ package shopping.wishlist.application import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service -import shopping.common.domain.CurrentUser +import shopping.common.domain.CurrentMember import shopping.product.application.ProductNotFoundException import shopping.product.domain.ProductRepository import shopping.wishlist.domain.WishlistProduct @@ -16,14 +16,14 @@ class WishlistService( ) { fun addWishlist( request: AddWishlistRequest, - currentUser: CurrentUser, + currentMember: CurrentMember, ): AddWishlistResponse { if (productRepository.existsById(request.productId).not()) { throw ProductNotFoundException(request.productId) } val wishlistProduct = - WishlistProduct(productId = request.productId, userId = currentUser.id).let { + WishlistProduct(productId = request.productId, memberId = currentMember.id).let { wishlistProductRepository.findByIdOrNull(it.id) ?: wishlistProductRepository.save(it) } @@ -34,9 +34,9 @@ class WishlistService( fun deleteWishlist( productId: Long, - currentUser: CurrentUser, + currentMember: CurrentMember, ) { - val id = WishlistProductId(productId, currentUser.id) + val id = WishlistProductId(productId = productId, memberId = currentMember.id) val wishlistProduct = wishlistProductRepository.findByIdOrNull(id) ?: return wishlistProductRepository.delete(wishlistProduct) diff --git a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt index 3ca21d6..75dd397 100644 --- a/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/shopping/wishlist/controller/WishlistController.kt @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import shopping.common.api.ApiResponse -import shopping.common.domain.CurrentUser +import shopping.common.domain.CurrentMember import shopping.wishlist.application.AddWishlistRequest import shopping.wishlist.application.AddWishlistResponse import shopping.wishlist.application.WishlistProductDto @@ -29,29 +29,29 @@ class WishlistController( @PostMapping fun addWishlist( @Valid @RequestBody request: AddWishlistRequest, - currentUser: CurrentUser, + currentMember: CurrentMember, ): ApiResponse { - val response = wishlistService.addWishlist(request = request, currentUser = currentUser) + val response = wishlistService.addWishlist(request = request, currentMember = currentMember) return ApiResponse.success(response) } @GetMapping fun getWishlists( - currentUser: CurrentUser, + currentMember: CurrentMember, @PageableDefault pageable: Pageable, ): ApiResponse> { - val response = wishlistQueryService.findAllByUserId(currentUser = currentUser, pageable = pageable) + val response = wishlistQueryService.findAllByUserId(currentMember = currentMember, pageable = pageable) return ApiResponse.success(response) } @DeleteMapping("/{productId}") fun deleteWishlist( - currentUser: CurrentUser, + currentMember: CurrentMember, @PathVariable("productId") productId: Long, ): ResponseEntity> { - wishlistService.deleteWishlist(productId = productId, currentUser = currentUser) + wishlistService.deleteWishlist(productId = productId, currentMember = currentMember) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt b/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt index a03d5b8..8a84ad0 100644 --- a/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt +++ b/src/main/kotlin/shopping/wishlist/domain/WishlistProduct.kt @@ -14,24 +14,23 @@ import java.time.LocalDateTime @Embeddable data class WishlistProductId( @Column - val userId: Long, + val memberId: Long, @Column val productId: Long, ) : Serializable @Entity @Table( - name = "wishlist", indexes = [ - Index(name = "ix_user_id_product_id", columnList = "userId, productId"), + Index(name = "ix_user_id_product_id", columnList = "memberId, productId"), ], ) class WishlistProduct( productId: Long, - userId: Long, + memberId: Long, ) { @EmbeddedId - val id: WishlistProductId = WishlistProductId(userId, productId) + val id: WishlistProductId = WishlistProductId(memberId, productId) @CreationTimestamp @Column From e08c64b2b6d2d7fa8f39d8deeb19c1118182975c Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 01:08:41 +0900 Subject: [PATCH 24/29] =?UTF-8?q?test(member):=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/shopping/common/util/StringUtil.kt | 6 + src/main/resources/application.yaml | 16 +++ src/test/kotlin/shopping/DatabaseCleaner.kt | 31 +++++ src/test/kotlin/shopping/E2ETest.kt | 7 +- .../member/controller/MemberControllerTest.kt | 108 ++++++++++++++++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/shopping/common/util/StringUtil.kt create mode 100644 src/test/kotlin/shopping/DatabaseCleaner.kt create mode 100644 src/test/kotlin/shopping/member/controller/MemberControllerTest.kt diff --git a/src/main/kotlin/shopping/common/util/StringUtil.kt b/src/main/kotlin/shopping/common/util/StringUtil.kt new file mode 100644 index 0000000..d816594 --- /dev/null +++ b/src/main/kotlin/shopping/common/util/StringUtil.kt @@ -0,0 +1,6 @@ +package shopping.common.util + +fun String.toSnakeCase(): String = + this.replace(Regex("([a-z])([A-Z]+)")) { + it.groupValues[1] + "_" + it.groupValues[2] + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 36baba9..37ed215 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -34,3 +34,19 @@ spring: jdbc-url: jdbc:h2:mem:testdb username: sa driver-class-name: org.h2.Driver + +--- + +spring: + config: + activate: + on-profile: test + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + diff --git a/src/test/kotlin/shopping/DatabaseCleaner.kt b/src/test/kotlin/shopping/DatabaseCleaner.kt new file mode 100644 index 0000000..143ce7f --- /dev/null +++ b/src/test/kotlin/shopping/DatabaseCleaner.kt @@ -0,0 +1,31 @@ +package shopping + +import jakarta.persistence.Entity +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import jakarta.transaction.Transactional +import org.springframework.stereotype.Component +import shopping.common.util.toSnakeCase + +@Component +class DatabaseCleaner( + @PersistenceContext + private val entityManager: EntityManager, +) { + @Transactional + fun clean() { + entityManager.clear() + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate() + getTableNames().forEach { + val tableName = it.name.toSnakeCase().lowercase() + entityManager.createNativeQuery("TRUNCATE TABLE $tableName").executeUpdate() + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate() + } + + private fun getTableNames() = + entityManager + .metamodel + .entities + .filter { it.javaType.isAnnotationPresent(Entity::class.java) } +} diff --git a/src/test/kotlin/shopping/E2ETest.kt b/src/test/kotlin/shopping/E2ETest.kt index 269121c..cabd37d 100644 --- a/src/test/kotlin/shopping/E2ETest.kt +++ b/src/test/kotlin/shopping/E2ETest.kt @@ -1,8 +1,8 @@ package shopping import io.restassured.RestAssured -import jakarta.transaction.Transactional import org.junit.jupiter.api.BeforeEach +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.test.context.ActiveProfiles @@ -11,14 +11,17 @@ import org.springframework.test.context.TestConstructor @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) -@Transactional abstract class E2ETest { @LocalServerPort protected var port: Int = 0 + @Autowired + private lateinit var databaseCleaner: DatabaseCleaner + @BeforeEach fun setup() { RestAssured.port = port RestAssured.baseURI = "http://localhost" + databaseCleaner.clean() } } diff --git a/src/test/kotlin/shopping/member/controller/MemberControllerTest.kt b/src/test/kotlin/shopping/member/controller/MemberControllerTest.kt new file mode 100644 index 0000000..a15cf4e --- /dev/null +++ b/src/test/kotlin/shopping/member/controller/MemberControllerTest.kt @@ -0,0 +1,108 @@ +package shopping.member.controller + +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import shopping.E2ETest +import shopping.common.error.ErrorCode +import shopping.member.application.MemberLoginRequest +import shopping.member.application.MemberRegistRequest + +class MemberControllerTest : E2ETest() { + @Test + fun `회원가입 성공`() { + val response = `회원가입 요청`(memberRegistRequest()).extract().jsonPath() + + assertThat(response.getString("data.accessToken")).isNotBlank() + } + + @ValueSource(strings = ["", "short", "123456789012345678901"]) + @ParameterizedTest + fun `비밀번호 형식에 맞지않으면 가입 불가능`(password: String) { + `회원가입 요청`(memberRegistRequest(password = password)) + .statusCode(400) + } + + @Test + fun `잘못된 이메일 형식 가입 불가능`() { + `회원가입 요청`(memberRegistRequest(email = "invalidemail.com")) + .statusCode(400) + } + + @Test + fun `이름 미기입시 가입 불가능`() { + `회원가입 요청`(memberRegistRequest(name = "")) + .statusCode(400) + } + + @Test + fun `로그인 성공`() { + val registRequest = memberRegistRequest() + `회원가입 요청`(registRequest) + val loginRequest = + memberLoginRequest( + email = registRequest.email, + password = registRequest.password, + ) + + val response = `로그인 요청`(loginRequest) + + assertThat(response.getString("data.accessToken")).isNotBlank() + } + + @Test + fun `비밀번호 불일치시 로그인 실패`() { + val registRequest = memberRegistRequest() + `회원가입 요청`(registRequest) + val loginRequest = + memberLoginRequest( + email = registRequest.email, + password = registRequest.password + "pad", + ) + + val response = `로그인 요청`(loginRequest) + + assertThat(response.getString("error.code")).isEqualTo(ErrorCode.PASSWORD_MISMATCH.code) + } + + private fun `회원가입 요청`(request: MemberRegistRequest) = + RestAssured + .given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/members/regist") + .then() + + private fun `로그인 요청`(request: MemberLoginRequest) = + RestAssured + .given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/members/login") + .then() + .extract() + .jsonPath() + + private fun memberRegistRequest( + email: String = "test@email.com", + password: String = "password!!@#", + name: String = "name", + ) = MemberRegistRequest( + name = name, + email = email, + password = password, + ) + + private fun memberLoginRequest( + email: String = "", + password: String = "", + ) = MemberLoginRequest( + email = email, + password = password, + ) +} From 3168b67197c149887014864154675efe2b944f4e Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 03:37:51 +0900 Subject: [PATCH 25/29] =?UTF-8?q?refactor(ProductTest):=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=83=9D=EC=84=B1=20=EC=9A=94=EC=B2=AD=20=ED=94=BD?= =?UTF-8?q?=EC=8A=A4=EC=B2=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CreateProductRequestFixture.kt | 18 +++++++++++++ .../controller/ProductControllerTest.kt | 27 +++++-------------- 2 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 src/test/kotlin/shopping/product/controller/CreateProductRequestFixture.kt diff --git a/src/test/kotlin/shopping/product/controller/CreateProductRequestFixture.kt b/src/test/kotlin/shopping/product/controller/CreateProductRequestFixture.kt new file mode 100644 index 0000000..a6ef042 --- /dev/null +++ b/src/test/kotlin/shopping/product/controller/CreateProductRequestFixture.kt @@ -0,0 +1,18 @@ +package shopping.product.controller + +import shopping.product.application.CreateProductRequest + +class CreateProductRequestFixture( + private val name: String = "[p_1] 상품", + private val price: Int = 1000, + private val imageUrl: String = "http://localhost:8080/image1", + private val stockQuantity: Int = 10, +) { + fun build(): CreateProductRequest = + CreateProductRequest( + name = name, + price = price, + imageUrl = imageUrl, + stockQuantity = stockQuantity, + ) +} diff --git a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt index 79e36f6..7bd5657 100644 --- a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt +++ b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt @@ -10,7 +10,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.springframework.boot.test.mock.mockito.MockBean import shopping.E2ETest -import shopping.product.application.CreateProductRequest import shopping.product.application.INVALID_NAME_LENGTH_MESSAGE import shopping.product.application.INVALID_NAME_PATTERN_MESSAGE import shopping.product.domain.BadWordValidator @@ -23,7 +22,7 @@ class ProductControllerTest( RestAssured .given() .contentType(ContentType.JSON) - .body(buildRequest()) + .body(CreateProductRequestFixture().build()) .`when`() .post("/products") .then() @@ -37,9 +36,9 @@ class ProductControllerTest( .given() .contentType(ContentType.JSON) .body( - buildRequest( + CreateProductRequestFixture( name = productName, - ), + ).build(), ).`when`() .post("/products") .then() @@ -53,9 +52,9 @@ class ProductControllerTest( .given() .contentType(ContentType.JSON) .body( - buildRequest( + CreateProductRequestFixture( name = "product !", - ), + ).build(), ).`when`() .post("/products") .then() @@ -69,24 +68,12 @@ class ProductControllerTest( RestAssured .given() .contentType(ContentType.JSON) - .body(buildRequest()) + .body(CreateProductRequestFixture().build()) .`when`() .post("/products") .then() - .statusCode(500) + .statusCode(400) .log() .all() } } - -private fun buildRequest( - name: String = "[p_1] 상품", - price: Int = 1000, - imageUrl: String = "http://localhost:8080/image1", - stockQuantity: Int = 10, -) = CreateProductRequest( - name = name, - price = price, - imageUrl = imageUrl, - stockQuantity = stockQuantity, -) From 97cb175a0abe0c7c8d215cb508b3353a467016cb Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 03:38:11 +0900 Subject: [PATCH 26/29] =?UTF-8?q?test(wishlist):=20=EC=9C=84=EC=8B=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/shopping/LoggedInE2ETest.kt | 30 +++++ .../controller/WishlistControllerTest.kt | 114 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/test/kotlin/shopping/LoggedInE2ETest.kt create mode 100644 src/test/kotlin/shopping/wishlist/controller/WishlistControllerTest.kt diff --git a/src/test/kotlin/shopping/LoggedInE2ETest.kt b/src/test/kotlin/shopping/LoggedInE2ETest.kt new file mode 100644 index 0000000..14cd932 --- /dev/null +++ b/src/test/kotlin/shopping/LoggedInE2ETest.kt @@ -0,0 +1,30 @@ +package shopping + +import org.junit.jupiter.api.BeforeEach +import org.springframework.beans.factory.annotation.Autowired +import shopping.member.application.MemberRegistRequest +import shopping.member.application.MemberService + +abstract class LoggedInE2ETest : E2ETest() { + @Autowired + private lateinit var memberService: MemberService + protected lateinit var accessToken: String + + @BeforeEach + override fun setup() { + super.setup() + setAccessToken() + } + + private fun setAccessToken() { + this.accessToken = + memberService + .regist( + MemberRegistRequest( + email = "test@test.com", + password = "testpassword12", + name = "tester", + ), + ).accessToken + } +} diff --git a/src/test/kotlin/shopping/wishlist/controller/WishlistControllerTest.kt b/src/test/kotlin/shopping/wishlist/controller/WishlistControllerTest.kt new file mode 100644 index 0000000..194a51b --- /dev/null +++ b/src/test/kotlin/shopping/wishlist/controller/WishlistControllerTest.kt @@ -0,0 +1,114 @@ +package shopping.wishlist.controller + +import io.restassured.RestAssured +import io.restassured.http.ContentType +import io.restassured.path.json.JsonPath +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import shopping.LoggedInE2ETest +import shopping.common.error.ErrorCode +import shopping.product.controller.CreateProductRequestFixture +import shopping.wishlist.application.AddWishlistRequest +import shopping.wishlist.application.WishlistProductDto + +class WishlistControllerTest : LoggedInE2ETest() { + @Test + fun `로그인하지 않으면 위시리스트 기능 사용불가능`() { + RestAssured + .given() + .`when`() + .get("/wishlists") + .then() + .statusCode(401) + } + + @Test + fun `위시리스트 상품 추가`() { + val productId = `상품 생성`() + + val response = `위시리스트 등록 요청`(AddWishlistRequest(productId)) + + assertThat(response.getLong("data.productId")).isEqualTo(productId) + } + + @Test + fun `없는 상품은 위시리스트에 추가할수없다`() { + val response = `위시리스트 등록 요청`(AddWishlistRequest(1L)) + + assertThat(response.getString("error.code")).isEqualTo(ErrorCode.PRODUCT_NOT_FOUND.code) + } + + @Test + fun `위시리스트 조회`() { + val productIds = listOf(`상품 생성`(), `상품 생성`()) + productIds.forEach { + `위시리스트 등록 요청`(AddWishlistRequest(it)) + } + + val response = `위시리스트 조회 요청`() + + val list = response.getList("data.content", WishlistProductDto::class.java) + assertThat(list.map { it.productId }).containsAll(productIds) + } + + @Test + fun `위시리스트 상품 삭제`() { + val productId = `상품 생성`() + `위시리스트 등록 요청`(AddWishlistRequest(productId)) + + `위시리스트 삭제 요청`(productId) + val response = `위시리스트 조회 요청`() + + assertThat(response.getList("data.content", WishlistProductDto::class.java)).isEmpty() + } + + private fun `위시리스트 조회 요청`(): JsonPath = + RestAssured + .given() + .auth() + .oauth2(accessToken) + .`when`() + .get("/wishlists") + .then() + .extract() + .jsonPath() + + private fun `위시리스트 삭제 요청`(productId: Long): JsonPath = + RestAssured + .given() + .auth() + .oauth2(accessToken) + .`when`() + .delete("/wishlists/$productId") + .then() + .log() + .all() + .extract() + .jsonPath() + + private fun `위시리스트 등록 요청`(request: AddWishlistRequest): JsonPath = + RestAssured + .given() + .contentType(ContentType.JSON) + .auth() + .oauth2(accessToken) + .body(request) + .`when`() + .post("/wishlists") + .then() + .extract() + .jsonPath() + + private fun `상품 생성`(): Long = + RestAssured + .given() + .contentType(ContentType.JSON) + .body(CreateProductRequestFixture().build()) + .`when`() + .post("/products") + .then() + .extract() + .jsonPath() + .getLong("data.id") +} From dbd3bf68518fff58e1a7534e554878374511a473 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 03:38:25 +0900 Subject: [PATCH 27/29] =?UTF-8?q?remove(WishlistTest):=20=EC=95=88?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wishlist/application/WishlistQueryServiceTest.kt | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt diff --git a/src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt b/src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt deleted file mode 100644 index 16c820b..0000000 --- a/src/test/kotlin/shopping/wishlist/application/WishlistQueryServiceTest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package shopping.wishlist.application - -import org.junit.jupiter.api.Assertions.* - -class WishlistQueryServiceTest From e95c87ae4937be30db5e2ab19c0b26a62ac5dc4e Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 17:00:35 +0900 Subject: [PATCH 28/29] =?UTF-8?q?test(product):=20=EB=B9=84=EC=86=8D?= =?UTF-8?q?=EC=96=B4=20=EA=B2=80=EC=82=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EB=8A=94=20Mock=20=EC=9C=BC=EB=A1=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/BadWordValidatorTestConfig.kt | 16 ++++++++++++++++ .../product/controller/ProductControllerTest.kt | 8 ++++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/shopping/config/BadWordValidatorTestConfig.kt diff --git a/src/test/kotlin/shopping/config/BadWordValidatorTestConfig.kt b/src/test/kotlin/shopping/config/BadWordValidatorTestConfig.kt new file mode 100644 index 0000000..68dde18 --- /dev/null +++ b/src/test/kotlin/shopping/config/BadWordValidatorTestConfig.kt @@ -0,0 +1,16 @@ +package shopping.config + +import org.mockito.kotlin.mock +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile +import shopping.product.domain.BadWordValidator + +@Profile("test") +@Configuration +class BadWordValidatorTestConfig { + @Bean + @Primary + fun badWordValidator(): BadWordValidator = mock() +} diff --git a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt index 7bd5657..9edd72d 100644 --- a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt +++ b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt @@ -7,15 +7,15 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import org.mockito.kotlin.any +import org.mockito.kotlin.reset import org.mockito.kotlin.whenever -import org.springframework.boot.test.mock.mockito.MockBean import shopping.E2ETest import shopping.product.application.INVALID_NAME_LENGTH_MESSAGE import shopping.product.application.INVALID_NAME_PATTERN_MESSAGE import shopping.product.domain.BadWordValidator class ProductControllerTest( - @MockBean private val badWordValidator: BadWordValidator, + private val badWordValidator: BadWordValidator, ) : E2ETest() { @Test fun `상품 등록 성공`() { @@ -73,7 +73,7 @@ class ProductControllerTest( .post("/products") .then() .statusCode(400) - .log() - .all() + + reset(badWordValidator) } } From 75c2d196fb335ec84ba3e99ecd4a351b0384a937 Mon Sep 17 00:00:00 2001 From: who-is-hu Date: Sun, 28 Jul 2024 17:26:06 +0900 Subject: [PATCH 29/29] =?UTF-8?q?refactor(ProductControllerTest):=20api=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProductControllerTest.kt | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt index 9edd72d..1ff7b78 100644 --- a/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt +++ b/src/test/kotlin/shopping/product/controller/ProductControllerTest.kt @@ -2,7 +2,8 @@ package shopping.product.controller import io.restassured.RestAssured import io.restassured.http.ContentType -import org.hamcrest.Matchers.equalTo +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.SoftAssertions.assertSoftly import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource @@ -10,6 +11,8 @@ import org.mockito.kotlin.any import org.mockito.kotlin.reset import org.mockito.kotlin.whenever import shopping.E2ETest +import shopping.common.error.ErrorCode +import shopping.product.application.CreateProductRequest import shopping.product.application.INVALID_NAME_LENGTH_MESSAGE import shopping.product.application.INVALID_NAME_PATTERN_MESSAGE import shopping.product.domain.BadWordValidator @@ -19,61 +22,51 @@ class ProductControllerTest( ) : E2ETest() { @Test fun `상품 등록 성공`() { - RestAssured - .given() - .contentType(ContentType.JSON) - .body(CreateProductRequestFixture().build()) - .`when`() - .post("/products") - .then() - .statusCode(200) + val request = CreateProductRequestFixture().build() + val response = `상품 생성 요청`(request) + + assertSoftly { softly -> + softly.assertThat(response.getLong("data.id")).isNotNull + softly.assertThat(response.getString("data.name")).isEqualTo(request.name) + softly.assertThat(response.getString("data.imageUrl")).isEqualTo(request.imageUrl) + } } @ValueSource(strings = ["", "1234512345123451"]) @ParameterizedTest(name = "상품명: {0}") fun `상품이름 길이를 만족해야한다`(productName: String) { - RestAssured - .given() - .contentType(ContentType.JSON) - .body( - CreateProductRequestFixture( - name = productName, - ).build(), - ).`when`() - .post("/products") - .then() - .statusCode(400) - .body("error.data[0].message", equalTo(INVALID_NAME_LENGTH_MESSAGE)) + val request = CreateProductRequestFixture(name = productName).build() + val response = `상품 생성 요청`(request) + + assertThat(response.getString("error.data[0].message")).isEqualTo(INVALID_NAME_LENGTH_MESSAGE) } @Test fun `허용되지 않는 특수문자는 등록할수 없다`() { - RestAssured - .given() - .contentType(ContentType.JSON) - .body( - CreateProductRequestFixture( - name = "product !", - ).build(), - ).`when`() - .post("/products") - .then() - .statusCode(400) - .body("error.data[0].message", equalTo(INVALID_NAME_PATTERN_MESSAGE)) + val request = CreateProductRequestFixture(name = "product !").build() + val response = `상품 생성 요청`(request) + + assertThat(response.getString("error.data[0].message")).isEqualTo(INVALID_NAME_PATTERN_MESSAGE) } @Test fun `이름에 영문 비속어가 있으면 등록할수 없다`() { whenever(badWordValidator.isBadWord(any())).thenReturn(true) + + val response = `상품 생성 요청`(CreateProductRequestFixture().build()) + + assertThat(response.getString("error.code")).isEqualTo(ErrorCode.PRODUCT_NAME_CONTAIN_BAD_WORD.code) + reset(badWordValidator) + } + + private fun `상품 생성 요청`(request: CreateProductRequest) = RestAssured .given() .contentType(ContentType.JSON) - .body(CreateProductRequestFixture().build()) + .body(request) .`when`() .post("/products") .then() - .statusCode(400) - - reset(badWordValidator) - } + .extract() + .jsonPath() }