diff --git a/.github/workflows/dev-aws-CI-CD.yml b/.github/workflows/dev-aws-CI-CD.yml index 686ae70..60bf6d7 100644 --- a/.github/workflows/dev-aws-CI-CD.yml +++ b/.github/workflows/dev-aws-CI-CD.yml @@ -40,8 +40,6 @@ jobs: run: chmod +x gradlew - name: Build With Gradle - env: - JASYPT_ENCRYPTOR_PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} run: ./gradlew build -x test --info - name: List All Files for Debugging @@ -75,16 +73,15 @@ jobs: - name: Show docker-compose.yml Content run: cat docker-compose.yml # docker-compose.yml 파일 내용 확인 - # Send nginx.conf to a temporary location - - name: Send nginx.conf to Home Directory + - name: Send docker-compose.yml and nginx.conf to Home Directory uses: appleboy/scp-action@master with: username: ubuntu host: ${{ secrets.AWS_DEV_HOSTNAME }} key: ${{ secrets.AWS_DEV_PRIVATE_KEY }} - source: "./nginx/conf.d/nginx.conf" - target: "/home/ubuntu" # 정확한 파일 경로 지정 - strip_components: 3 # 경로 구성 요소를 제거하여 파일만 전송 + source: "./docker-compose.yml,./nginx/conf.d/nginx.conf" + target: "/home/ubuntu/" + strip_components: 3 # nginx.conf 경로 구성 요소를 제거하여 파일만 전송 # Move nginx.conf from Home Directory to /etc/nginx/conf.d - name: Move nginx.conf to /etc/nginx/conf.d @@ -143,7 +140,6 @@ jobs: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} export DOCKER_REPOSITORY=${{ secrets.DOCKER_REPOSITORY }} export DOCKER_REPOSITORY_NGINX=${{ secrets.DOCKER_REPOSITORY_NGINX }} - export JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} # Pull 최신 이미지 docker-compose -f /home/ubuntu/docker-compose.yml pull # 새 컨테이너 실행 diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 4f4d063..0dc2e3c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -30,8 +30,6 @@ jobs: run: | ./gradlew build ./gradlew --info test - env: - JASYPT_ENCRYPTOR_PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v1 diff --git a/README.md b/README.md index 97ac895..ea17843 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,6 @@ ## 프로젝트 기간 이 프로젝트의 기간은 2024년 7월 23일부터 2024년 8월 30일까지입니다. + +## CI/CD Architecture +![image](https://github.com/user-attachments/assets/8dc985e5-68b8-4982-897b-c8e60d8b1b6f) diff --git a/build.gradle.kts b/build.gradle.kts index 5a6afdb..64c4db4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,9 @@ dependencies { //web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + + //thymeleaf implementation("org.springframework.boot:spring-boot-starter-thymeleaf") // logging @@ -69,6 +72,12 @@ dependencies { testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion}") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("com.squareup.okhttp3:okhttp:4.9.1") + testImplementation("com.squareup.okhttp3:mockwebserver:4.9.1") + + //oauth2 + implementation ("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation ("org.springframework.security:spring-security-oauth2-jose") //etc implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") //swagger diff --git a/src/main/kotlin/org/store/clothstar/common/config/SecurityConfiguration.kt b/src/main/kotlin/org/store/clothstar/common/config/SecurityConfiguration.kt index 0a027b7..28a6278 100644 --- a/src/main/kotlin/org/store/clothstar/common/config/SecurityConfiguration.kt +++ b/src/main/kotlin/org/store/clothstar/common/config/SecurityConfiguration.kt @@ -73,6 +73,15 @@ class SecurityConfiguration( "/v1/orders/list", "/ordersPagingOffset", "/ordersPagingSlice", "/v2/orders/list", "/v1/seller/orders/**", "/v1/seller/orders", "/v1/orders/**", "/v1/orderdetails/**", "/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs/**", "/v1/members/auth/**", + "/auth/**", "/kakaoLogin/**", "/kakao_login_medium_narrow.png", "/v1/members?signUpType=KAKAO", + "/auth/**", "/kakaoLogin/**", "/kakao_login_medium_narrow.png", + "/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs/**", "/v1/members/auth/**", + "config-service/**", + "/auth/**", "/kakaoLogin/**", "/kakao_login_medium_narrow.png", "/v1/members?signUpType=KAKAO", + "/auth/**", "/kakaoLogin/**", "/kakao_login_medium_narrow.png", "/v1/members?signUpType=KAKAO", + "/v1/members?signUpType=KAKAO", + "/v1/members/**", + "/v1/members/auth/**", "config-service/**", "products/**", "productDetail", "payment/**", "/v1/payments/**" ).permitAll() .requestMatchers(HttpMethod.POST, "/v1/members").permitAll() diff --git a/src/main/kotlin/org/store/clothstar/common/config/WebConfig.kt b/src/main/kotlin/org/store/clothstar/common/config/WebConfig.kt new file mode 100644 index 0000000..beb2acf --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/common/config/WebConfig.kt @@ -0,0 +1,20 @@ +package org.store.clothstar.common.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig : WebMvcConfigurer { + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("/favicon.ico") + .addResourceLocations("classpath:/static/") + registry.addResourceHandler("/js/**") + .addResourceLocations("classpath:/static/js/") + registry.addResourceHandler("/css/**") + .addResourceLocations("classpath:/static/css/") + registry.addResourceHandler("/images/**") + .addResourceLocations("classpath:/static/images/") + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/common/config/exception/GlobalExceptionHandler.kt b/src/main/kotlin/org/store/clothstar/common/config/exception/GlobalExceptionHandler.kt index b83d62b..c9a706c 100644 --- a/src/main/kotlin/org/store/clothstar/common/config/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/org/store/clothstar/common/config/exception/GlobalExceptionHandler.kt @@ -190,6 +190,19 @@ class GlobalExceptionHandler { return ResponseEntity(errorResponseDTO, ex.errorCode.status) } + // 회원가입 관련 에러처리 + @ExceptionHandler(InvalidSignupMemberRequest::class) + fun handleInvalidSignupMemberRequest(ex: InvalidSignupMemberRequest): ResponseEntity { + val errorResponseDTO = ErrorResponseDTO(ex.errorCode.status.value(), ex.errorCode.message) + return ResponseEntity(errorResponseDTO, ex.errorCode.status) + } + + @ExceptionHandler(InvalidSignupType::class) + fun handleInvalidSignupType(ex: InvalidSignupType): ResponseEntity { + val errorResponseDTO = ErrorResponseDTO(ex.errorCode.status.value(), ex.errorCode.message) + return ResponseEntity(errorResponseDTO, ex.errorCode.status) + } + // Order 관련 에러처리 @ExceptionHandler(OrderNotFoundException::class) fun handleOrderNotFoundException(ex: OrderNotFoundException): ResponseEntity { diff --git a/src/main/kotlin/org/store/clothstar/common/dto/SaveResponseDTO.kt b/src/main/kotlin/org/store/clothstar/common/dto/SaveResponseDTO.kt index 79c46dc..10c8447 100644 --- a/src/main/kotlin/org/store/clothstar/common/dto/SaveResponseDTO.kt +++ b/src/main/kotlin/org/store/clothstar/common/dto/SaveResponseDTO.kt @@ -1,7 +1,7 @@ package org.store.clothstar.common.dto class SaveResponseDTO( - val id: Long, + val id: Long? = null, val statusCode: Int, val message: String, ) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/common/error/ErrorCode.kt b/src/main/kotlin/org/store/clothstar/common/error/ErrorCode.kt index 94475c9..7ec3ec0 100644 --- a/src/main/kotlin/org/store/clothstar/common/error/ErrorCode.kt +++ b/src/main/kotlin/org/store/clothstar/common/error/ErrorCode.kt @@ -22,6 +22,10 @@ enum class ErrorCode( INVALID_ROLE(HttpStatus.BAD_REQUEST, "계정 ID: %d - 이 계정은 요청된 권한(%s)을 가지고 있지 않습니다."), + // 회원가입 관련 에러코드 + INVALID_SIGNUP_MEMBER_REQUEST(HttpStatus.BAD_REQUEST, "회원가입 시 회원 정보가 필요합니다."), + INVLIAD_SIGNUP_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 회원가입 유형입니다."), + // Order 관련 에러코드 NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, "존재하지 않는 주문번호입니다."), INVALID_ORDER_STATUS_CONFIRMED(HttpStatus.BAD_REQUEST, "주문이 '입금확인' 상태가 아니므로 요청을 처리할 수 없습니다."), @@ -29,7 +33,6 @@ enum class ErrorCode( OUT_OF_STOCK(HttpStatus.BAD_REQUEST, "품절된 상품입니다."), INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "주문 개수가 상품 재고보다 더 많아 요청을 처리할 수 없습니다."); - // Product 관련 에러코드 diff --git a/src/main/kotlin/org/store/clothstar/common/error/exception/InvalidSignupMemberRequest.kt b/src/main/kotlin/org/store/clothstar/common/error/exception/InvalidSignupMemberRequest.kt new file mode 100644 index 0000000..039a8dd --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/common/error/exception/InvalidSignupMemberRequest.kt @@ -0,0 +1,7 @@ +package org.store.clothstar.common.error.exception + +import org.store.clothstar.common.error.ErrorCode + +class InvalidSignupMemberRequest( + val errorCode: ErrorCode +) : RuntimeException(errorCode.message) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/common/error/exception/InvalidSignupType.kt b/src/main/kotlin/org/store/clothstar/common/error/exception/InvalidSignupType.kt new file mode 100644 index 0000000..f88946b --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/common/error/exception/InvalidSignupType.kt @@ -0,0 +1,7 @@ +package org.store.clothstar.common.error.exception + +import org.store.clothstar.common.error.ErrorCode + +class InvalidSignupType( + val errorCode: ErrorCode +) : RuntimeException(errorCode.message) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/controller/KakaoController.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/controller/KakaoController.kt new file mode 100644 index 0000000..d576b28 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/controller/KakaoController.kt @@ -0,0 +1,18 @@ +package org.store.clothstar.kakaoLogin.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.store.clothstar.kakaoLogin.service.KakaoLoginService + +@RestController +class KakaoController( + private val kakaoLoginService: KakaoLoginService, +) { + // 인가코드 받기 + @GetMapping("/auth/kakao/callback") + fun kakaoCallback(@RequestParam code: String): ResponseEntity { + return ResponseEntity.ok(code) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/controller/KakaoLoginPageController.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/controller/KakaoLoginPageController.kt new file mode 100644 index 0000000..92bfb12 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/controller/KakaoLoginPageController.kt @@ -0,0 +1,25 @@ +package org.store.clothstar.kakaoLogin.controller + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping + +@Controller +class KakaoLoginPageController { + + @Value("\${spring.security.oauth2.client.registration.kakao.client_id}") + private lateinit var clientId: String + + @Value("\${spring.security.oauth2.client.registration.kakao.redirect_uri}") + private lateinit var redirectUri: String + + @GetMapping("/kakaoLogin/page") + fun loginPage(model: Model): String { + val location = + "https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=$clientId&redirect_uri=$redirectUri" + model.addAttribute("location", location) + + return "kakaoLogin" + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/dto/KakaoTokenResponseDto.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/dto/KakaoTokenResponseDto.kt new file mode 100644 index 0000000..29eb460 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/dto/KakaoTokenResponseDto.kt @@ -0,0 +1,31 @@ +package org.store.clothstar.kakaoLogin.dto + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoTokenResponseDto @JsonCreator constructor( + + // 토큰 타입, bearer로 고정 + @JsonProperty("token_type") + private val tokenType: String? = null, + + // 사용자 액세스 토큰 값 + @JsonProperty("access_token") + val accessToken: String? = null, + + // 액세스 토큰과 ID 토큰의 만료 시간(초) + @JsonProperty("expires_in") + private val expiresIn: Int? = null, + + // 사용자 리프레시 토큰 값 + @JsonProperty("refresh_token") + private val refreshToken: String? = null, + + // 리프레시 토큰 만료 시간(초) + @JsonProperty("refresh_token_expires_in") + private val refreshTokenExpiresIn: Int? = null, + + // 인증된 사용자의 정보 조회 권한 범위 / 범위가 여러 개일 경우, 공백으로 구분 + @JsonProperty("scope") + private val scope: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/dto/KakaoUserInfoResponseDto.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/dto/KakaoUserInfoResponseDto.kt new file mode 100644 index 0000000..75b91c2 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/dto/KakaoUserInfoResponseDto.kt @@ -0,0 +1,16 @@ +package org.store.clothstar.kakaoLogin.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import org.store.clothstar.kakaoLogin.vo.KakaoAccount +import org.store.clothstar.kakaoLogin.vo.Properties + +class KakaoUserInfoResponseDto( + // 회원번호 + @JsonProperty("id") val id: Long? = null, + // 서비스에 연결 완료된 시각, UTC* + @JsonProperty("connected_at") val connectedAt: String? = null, + // 카카오계정 정보 + @JsonProperty("kakao_account") val kakaoAccount: KakaoAccount? = null, + // 사용자 프로퍼티(Property) + @JsonProperty("properties") val properties: Properties? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/service/KakaoLoginService.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/service/KakaoLoginService.kt new file mode 100644 index 0000000..512257b --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/service/KakaoLoginService.kt @@ -0,0 +1,87 @@ +package org.store.clothstar.kakaoLogin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.store.clothstar.kakaoLogin.dto.KakaoTokenResponseDto +import org.store.clothstar.kakaoLogin.dto.KakaoUserInfoResponseDto + +@EnableScheduling +@Service +class KakaoLoginService { + + private val logger = KotlinLogging.logger {} + + @Value("\${spring.security.oauth2.client.registration.kakao.client_id}") + private lateinit var clientId: String + + @Value("\${spring.security.oauth2.client.registration.kakao.client_secret}") + private lateinit var clientSecret: String + + @Value("\${spring.security.oauth2.client.registration.kakao.redirect_uri}") + private lateinit var redirectUri: String + + @Value("\${spring.security.oauth2.client.provider.kakao.token_uri}") + lateinit var tokenUri: String + + @Value("\${spring.security.oauth2.client.provider.kakao.user_info_uri}") + lateinit var userUri: String + + // 토큰 가져오기 + fun getAccessToken(code: String): KakaoTokenResponseDto { + // 토큰 요청 데이터 + val params: MultiValueMap = LinkedMultiValueMap() + params.add("code", code) + params.add("client_secret", clientSecret) + params.add("client_id", clientId) + params.add("grant_type", "authorization_code") + params.add("redirect_url", redirectUri) + + logger.info { "Requesting token with params: $params" } + + // 웹 클라이언트로 요청 보내기 + val response = WebClient.create(tokenUri) + .post() + .body(BodyInserters.fromFormData(params)) + .header("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") + .retrieve() + .bodyToMono(String::class.java) + .block() + + logger.info { "Token response: $response" } + + // json 응답을 객체로 변환 + val objectMapper = ObjectMapper() + val kakaoToken: KakaoTokenResponseDto = objectMapper.readValue(response, KakaoTokenResponseDto::class.java) + + logger.info { "Access Token : ${kakaoToken.accessToken}" } + return kakaoToken + } + + // 사용자 정보 가져오기 + fun getUserInfo(accessToken: String): KakaoUserInfoResponseDto { + // 웹 클라이언트로 요청 보내기 + val response = WebClient.create(userUri) + .get() + .header("Authorization", "Bearer $accessToken") + .header("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") + .retrieve() + .bodyToMono(String::class.java) + .block() + + logger.info { "User info response: $response" } + + // json 응답을 객체로 변환 + val objectMapper = ObjectMapper() + val userInfo = objectMapper.readValue(response, KakaoUserInfoResponseDto::class.java) + + logger.info { "email : ${userInfo.kakaoAccount!!.email}" } + return userInfo + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/vo/KakaoAccount.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/vo/KakaoAccount.kt new file mode 100644 index 0000000..77694fc --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/vo/KakaoAccount.kt @@ -0,0 +1,56 @@ +package org.store.clothstar.kakaoLogin.vo + +import com.fasterxml.jackson.annotation.JsonProperty + +class KakaoAccount( + // 사용자 동의 시 닉네임 제공 가능 + // 필요한 동의항목: 닉네임 + @JsonProperty("profile_nickname_needs_agreement") val profileNicknameNeedsAgreement: Boolean? = null, + // 사용자 동의 시 프로필 사진 제공 가능 + // 필요한 동의항목: 프로필 사진 + @JsonProperty("profile_image_needs_agreement") val profileImageNeedsAgreement: Boolean? = null, + // 이메일 유무 여부 + // 필요한 동의항목: 카카오계정(이메일) + @JsonProperty("has_email") val hasEmail: Boolean? = null, + // 사용자 동의 시 카카오계정 대표 이메일 제공 가능 + // 필요한 동의항목: 카카오계정(이메일) + @JsonProperty("email_needs_agreement") val emailNeedsAgreement: Boolean? = null, + // 이메일 유효 여부 + // 필요한 동의항목: 카카오계정(이메일) + // true: 유효한 이메일 / false: 이메일이 다른 카카오계정에 사용돼 만료 + @JsonProperty("is_email_valid") val isEmailValid: Boolean? = null, + // 이메일 인증 여부 + // 필요한 동의항목: 카카오계정(이메일) + // true: 인증된 이메일 / false: 인증되지 않은 이메일 + @JsonProperty("is_email_verified") val isEmailVerified: Boolean? = null, + // 카카오계정 대표 이메일 + // 필요한 동의항목: 카카오계정(이메일) + @JsonProperty("email") val email: String? = null, + // 프로필 정보 + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진), 닉네임, 프로필 사진 + @JsonProperty("profile") val profile: Profile? = null, +) { + data class Profile( + // 닉네임 + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 닉네임 + @JsonProperty("nickname") val nickname: String? = null, + // 프로필 미리보기 이미지 URL + // 110px * 110px 또는 100px * 100px + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 프로필 사진 + @JsonProperty("thumbnail_image_url") val thumbnailImageUrl: String? = null, + // 프로필 사진 URL + // 640px * 640px 또는 480px * 480px + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 프로필 사진 + @JsonProperty("profile_image_url") val profileImageUrl: String? = null, + // 프로필 사진 URL이 기본 프로필 사진 URL인지 여부 + // 사용자가 등록한 프로필 사진이 없을 경우, 기본 프로필 사진 제공 + // true: 기본 프로필 사진 / false: 사용자가 등록한 프로필 사진 + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 프로필 사진 + @JsonProperty("is_default_image") val isDefaultImage: Boolean? = null, + // 닉네임이 기본 닉네임인지 여부 + // 사용자가 등록한 닉네임이 운영정책에 부합하지 않는 경우, "닉네임을 등록해주세요"가 기본 닉네임으로 적용됨 + // true: 기본 닉네임 / false: 사용자가 등록한 닉네임 + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 닉네임 + @JsonProperty("is_default_nickname") val isDefaultNickname: Boolean? = null + ) +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/kakaoLogin/vo/Properties.kt b/src/main/kotlin/org/store/clothstar/kakaoLogin/vo/Properties.kt new file mode 100644 index 0000000..40ee579 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/kakaoLogin/vo/Properties.kt @@ -0,0 +1,19 @@ +package org.store.clothstar.kakaoLogin.vo + +import com.fasterxml.jackson.annotation.JsonProperty + +class Properties( + // 닉네임 + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 닉네임 + @JsonProperty("nickname") val nickname: String? = null, + // 프로필 사진 URL + // 640px * 640px 또는 480px * 480px + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 프로필 사진 + @JsonProperty("profile_image") val profileImage: String? = null, + // 프로필 미리보기 이미지 URL + // 110px * 110px 또는 100px * 100px + // 필요한 동의항목: 프로필 정보(닉네임/프로필 사진) 또는 프로필 사진 + @JsonProperty("thumbnail_image") val thumbnailImage: String? = null, + // 테스트용 사용자 프로퍼티 + @JsonProperty("test_property") val testProperty: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationController.kt b/src/main/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationController.kt index dd7a014..b69a459 100644 --- a/src/main/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationController.kt +++ b/src/main/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationController.kt @@ -14,17 +14,25 @@ import org.springframework.web.bind.annotation.* import org.store.clothstar.common.dto.ErrorResponseDTO import org.store.clothstar.common.dto.MessageDTO import org.store.clothstar.common.dto.SaveResponseDTO +import org.store.clothstar.common.error.ErrorCode +import org.store.clothstar.common.error.exception.InvalidSignupMemberRequest +import org.store.clothstar.common.error.exception.InvalidSignupType import org.store.clothstar.member.application.MemberServiceApplication +import org.store.clothstar.member.authentication.domain.SignUpType +import org.store.clothstar.member.authentication.service.KakaoSignUpService +import org.store.clothstar.member.authentication.service.NormalSignUpService +import org.store.clothstar.member.authentication.service.SignUpServiceFactory import org.store.clothstar.member.dto.request.CertifyNumRequest -import org.store.clothstar.member.dto.request.CreateMemberRequest import org.store.clothstar.member.dto.request.MemberLoginRequest import org.store.clothstar.member.dto.request.ModifyPasswordRequest +import org.store.clothstar.member.dto.request.SignUpRequest import org.store.clothstar.member.dto.response.MemberResponse @Tag(name = "Auth", description = "회원가입과 인증에 관한 API 입니다.") @RestController class AuthenticationController( private val memberServiceApplication: MemberServiceApplication, + private val signUpServiceFactory: SignUpServiceFactory, ) { private val log = KotlinLogging.logger {} @@ -62,15 +70,35 @@ class AuthenticationController( @Operation(summary = "회원가입", description = "회원가입시 회원 정보를 저장한다.") @PostMapping("/v1/members") - fun signup(@Validated @RequestBody createMemberDTO: CreateMemberRequest): ResponseEntity { - val memberId = memberServiceApplication.signUp(createMemberDTO) + fun signup( + @Validated @RequestBody signUpRequest: SignUpRequest, + @RequestParam signUpType: SignUpType, + ): ResponseEntity { + val signUpService = signUpServiceFactory.getSignUpService(signUpType) + log.info { "SignUpService 종류: $signUpService" } + + val memberId = when (signUpService) { + // 일반 회원가입 + is NormalSignUpService -> { + val normalMemberRequest = signUpRequest.createMemberRequest + ?: throw InvalidSignupMemberRequest(ErrorCode.INVALID_SIGNUP_MEMBER_REQUEST) + signUpService.signUp(normalMemberRequest) + } + // 카카오 회원가입 + is KakaoSignUpService -> { + val kakaoMemberRequest = signUpRequest.kakaoMemberRequest + ?: throw InvalidSignupMemberRequest(ErrorCode.INVALID_SIGNUP_MEMBER_REQUEST) + signUpService.signUp(kakaoMemberRequest) + } + + else -> throw InvalidSignupType(ErrorCode.INVLIAD_SIGNUP_TYPE) + } val saveResponseDTO = SaveResponseDTO( id = memberId, statusCode = HttpStatus.CREATED.value(), message = "회원가입이 정상적으로 되었습니다.", ) - return ResponseEntity(saveResponseDTO, HttpStatus.CREATED) } diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/domain/CustomUserDetails.kt b/src/main/kotlin/org/store/clothstar/member/authentication/domain/CustomUserDetails.kt index 801af74..3c5bfb2 100644 --- a/src/main/kotlin/org/store/clothstar/member/authentication/domain/CustomUserDetails.kt +++ b/src/main/kotlin/org/store/clothstar/member/authentication/domain/CustomUserDetails.kt @@ -13,7 +13,7 @@ class CustomUserDetails( } override fun getPassword(): String { - return account.password + return account.password!! } override fun getUsername(): String { diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/domain/SignUpType.kt b/src/main/kotlin/org/store/clothstar/member/authentication/domain/SignUpType.kt new file mode 100644 index 0000000..db51178 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/authentication/domain/SignUpType.kt @@ -0,0 +1,6 @@ +package org.store.clothstar.member.authentication.domain + +enum class SignUpType { + NORMAL, + KAKAO, +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/service/KakaoSignUpService.kt b/src/main/kotlin/org/store/clothstar/member/authentication/service/KakaoSignUpService.kt new file mode 100644 index 0000000..9d046a3 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/authentication/service/KakaoSignUpService.kt @@ -0,0 +1,38 @@ +package org.store.clothstar.member.authentication.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.store.clothstar.kakaoLogin.service.KakaoLoginService +import org.store.clothstar.member.dto.request.KakaoMemberRequest +import org.store.clothstar.member.service.AccountService +import org.store.clothstar.member.service.MemberService + +@Service +class KakaoSignUpService( + private val memberService: MemberService, + private val accountService: AccountService, + private val kakaoLoginService: KakaoLoginService, +) : SignUpService { + private val log = KotlinLogging.logger {} + + override fun signUp(request: KakaoMemberRequest): Long { + // 액세스 토큰 받아오기 + val accessToken = kakaoLoginService.getAccessToken(request.code) + + // 사용자 정보 받아오기 + val userInfo = kakaoLoginService.getUserInfo(accessToken.accessToken!!) + + // kakaoMemberRequest의 이메일 필드 업데이트 + val updatedKakaoMemberRequest = + request.addEmail(userInfo.kakaoAccount!!.email!!) + + // Member DB 저장 + val memberId = memberService.saveKakaoMember(updatedKakaoMemberRequest) + // Account DB 저장 + accountService.saveKakaoAccount(memberId, updatedKakaoMemberRequest) + + log.info { "KAKAOSIGNUPSERVICE 입니다" } + + return memberId + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/service/NormalSignUpService.kt b/src/main/kotlin/org/store/clothstar/member/authentication/service/NormalSignUpService.kt new file mode 100644 index 0000000..b199667 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/authentication/service/NormalSignUpService.kt @@ -0,0 +1,20 @@ +package org.store.clothstar.member.authentication.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.store.clothstar.member.application.MemberServiceApplication +import org.store.clothstar.member.dto.request.CreateMemberRequest + +@Service +class NormalSignUpService( + private val memberServiceApplication: MemberServiceApplication +) : SignUpService { + private val log = KotlinLogging.logger {} + + override fun signUp(request: CreateMemberRequest): Long { + + log.info { "NORMALSIGNUPSERVICE 입니다" } + + return memberServiceApplication.signUp(request) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/service/SignUpService.kt b/src/main/kotlin/org/store/clothstar/member/authentication/service/SignUpService.kt new file mode 100644 index 0000000..8351f00 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/authentication/service/SignUpService.kt @@ -0,0 +1,5 @@ +package org.store.clothstar.member.authentication.service + +interface SignUpService { + fun signUp(request: T): Long +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/authentication/service/SignUpServiceFactory.kt b/src/main/kotlin/org/store/clothstar/member/authentication/service/SignUpServiceFactory.kt new file mode 100644 index 0000000..254822d --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/authentication/service/SignUpServiceFactory.kt @@ -0,0 +1,19 @@ +package org.store.clothstar.member.authentication.service + +import org.springframework.stereotype.Component +import org.store.clothstar.member.authentication.domain.SignUpType +import org.store.clothstar.member.dto.request.CreateMemberRequest +import org.store.clothstar.member.dto.request.KakaoMemberRequest + +@Component +class SignUpServiceFactory( + private val normalSignUpService: SignUpService, + private val kakaoSignUpService: SignUpService +) { + fun getSignUpService(signUpType: SignUpType): SignUpService<*> { + return when (signUpType) { + SignUpType.NORMAL -> normalSignUpService + SignUpType.KAKAO -> kakaoSignUpService + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/domain/Account.kt b/src/main/kotlin/org/store/clothstar/member/domain/Account.kt index 955fe23..7f09d98 100644 --- a/src/main/kotlin/org/store/clothstar/member/domain/Account.kt +++ b/src/main/kotlin/org/store/clothstar/member/domain/Account.kt @@ -11,7 +11,7 @@ class Account( @Column(unique = true) val email: String, //getter 자동 생성 - var password: String, + var password: String? = null, @Enumerated(EnumType.STRING) var role: MemberRole, diff --git a/src/main/kotlin/org/store/clothstar/member/dto/request/CreateKakaoMemberRequest.kt b/src/main/kotlin/org/store/clothstar/member/dto/request/CreateKakaoMemberRequest.kt new file mode 100644 index 0000000..4d72c23 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/dto/request/CreateKakaoMemberRequest.kt @@ -0,0 +1,16 @@ +package org.store.clothstar.member.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +class CreateKakaoMemberRequest( + @field: Email(message = "유효하지 않은 이메일 형식입니다.") + val email: String, + + @field: NotBlank(message = "이름은 비어 있을 수 없습니다.") + val name: String, + + @field: Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "유효하지 않은 전화번호 형식입니다.") + val telNo: String, +) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/dto/request/KakaoMemberRequest.kt b/src/main/kotlin/org/store/clothstar/member/dto/request/KakaoMemberRequest.kt new file mode 100644 index 0000000..70985c3 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/dto/request/KakaoMemberRequest.kt @@ -0,0 +1,20 @@ +package org.store.clothstar.member.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +class KakaoMemberRequest( + @field: NotBlank(message = "이름은 비어 있을 수 없습니다.") + val name: String, + + @field: Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "유효하지 않은 전화번호 형식입니다.") + val telNo: String, + + val email: String?, + + val code: String, +) { + fun addEmail(email: String): KakaoMemberRequest { + return KakaoMemberRequest(name, telNo, email, code) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/dto/request/SignUpRequest.kt b/src/main/kotlin/org/store/clothstar/member/dto/request/SignUpRequest.kt new file mode 100644 index 0000000..c44f192 --- /dev/null +++ b/src/main/kotlin/org/store/clothstar/member/dto/request/SignUpRequest.kt @@ -0,0 +1,9 @@ +package org.store.clothstar.member.dto.request + +import jakarta.validation.Valid + +class SignUpRequest( + @field: Valid + val createMemberRequest: CreateMemberRequest?, + var kakaoMemberRequest: KakaoMemberRequest? +) \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/service/AccountService.kt b/src/main/kotlin/org/store/clothstar/member/service/AccountService.kt index 11d489b..c9f6bfc 100644 --- a/src/main/kotlin/org/store/clothstar/member/service/AccountService.kt +++ b/src/main/kotlin/org/store/clothstar/member/service/AccountService.kt @@ -3,9 +3,11 @@ package org.store.clothstar.member.service import org.store.clothstar.member.domain.Account import org.store.clothstar.member.domain.MemberRole import org.store.clothstar.member.dto.request.CreateMemberRequest +import org.store.clothstar.member.dto.request.KakaoMemberRequest interface AccountService { fun saveAccount(memberId: Long, createMemberDTO: CreateMemberRequest): Account + fun saveKakaoAccount(memberId: Long, createKakaoMemberDTO: KakaoMemberRequest): Account fun updateRole(memberId: Long, findRole: MemberRole, updateRole: MemberRole) fun updateDeletedAt(memberId: Long, findRole: MemberRole) } \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/service/AccountServiceImpl.kt b/src/main/kotlin/org/store/clothstar/member/service/AccountServiceImpl.kt index 52fcb68..9bbf375 100644 --- a/src/main/kotlin/org/store/clothstar/member/service/AccountServiceImpl.kt +++ b/src/main/kotlin/org/store/clothstar/member/service/AccountServiceImpl.kt @@ -9,6 +9,7 @@ import org.store.clothstar.common.error.exception.NotFoundAccountException import org.store.clothstar.member.domain.Account import org.store.clothstar.member.domain.MemberRole import org.store.clothstar.member.dto.request.CreateMemberRequest +import org.store.clothstar.member.dto.request.KakaoMemberRequest import org.store.clothstar.member.repository.AccountRepository @Service @@ -30,6 +31,22 @@ class AccountServiceImpl( return accountRepository.save(account) } + @Transactional + override fun saveKakaoAccount(memberId: Long, createKakaoMemberDTO: KakaoMemberRequest): Account { + // 이메일 중복 검사 + accountRepository.findByEmail(createKakaoMemberDTO.email!!)?.let { + throw DuplicatedEmailException(ErrorCode.DUPLICATED_EMAIL) + } + + val account = Account( + email = createKakaoMemberDTO.email, + role = MemberRole.USER, + userId = memberId, + ) + + return accountRepository.save(account) + } + @Transactional override fun updateRole(memberId: Long, findRole: MemberRole, updateRole: MemberRole) { val account = accountRepository.findByUserIdAndRole(memberId, findRole) diff --git a/src/main/kotlin/org/store/clothstar/member/service/MemberService.kt b/src/main/kotlin/org/store/clothstar/member/service/MemberService.kt index 004f039..3bbb22f 100644 --- a/src/main/kotlin/org/store/clothstar/member/service/MemberService.kt +++ b/src/main/kotlin/org/store/clothstar/member/service/MemberService.kt @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice import org.store.clothstar.member.domain.Member import org.store.clothstar.member.dto.request.CreateMemberRequest +import org.store.clothstar.member.dto.request.KakaoMemberRequest import org.store.clothstar.member.dto.request.ModifyNameRequest import org.store.clothstar.member.dto.response.MemberResponse @@ -24,6 +25,7 @@ interface MemberService { fun modifyName(memberId: Long, modifyNameRequest: ModifyNameRequest) fun saveMember(createMemberDTO: CreateMemberRequest): Long + fun saveKakaoMember(createKakaoMemberDTO: KakaoMemberRequest): Long fun getMemberByMemberId(memberId: Long): Member } \ No newline at end of file diff --git a/src/main/kotlin/org/store/clothstar/member/service/MemberServiceImpl.kt b/src/main/kotlin/org/store/clothstar/member/service/MemberServiceImpl.kt index 99fcfbe..d97567e 100644 --- a/src/main/kotlin/org/store/clothstar/member/service/MemberServiceImpl.kt +++ b/src/main/kotlin/org/store/clothstar/member/service/MemberServiceImpl.kt @@ -15,6 +15,7 @@ import org.store.clothstar.common.error.exception.NotFoundMemberException import org.store.clothstar.member.domain.Member import org.store.clothstar.member.domain.vo.MemberShoppingActivity import org.store.clothstar.member.dto.request.CreateMemberRequest +import org.store.clothstar.member.dto.request.KakaoMemberRequest import org.store.clothstar.member.dto.request.ModifyNameRequest import org.store.clothstar.member.dto.response.MemberResponse import org.store.clothstar.member.repository.AccountRepository @@ -108,7 +109,7 @@ class MemberServiceImpl( ?: throw NotFoundMemberException(ErrorCode.NOT_FOUND_ACCOUNT) val encodedPassword = passwordEncoder.encode(password) - val originalPassword: String = account.password + val originalPassword: String = account.password!! //valid check if (!passwordEncoder.matches(originalPassword, encodedPassword)) { @@ -134,6 +135,24 @@ class MemberServiceImpl( return member.memberId!! } + @Transactional + override fun saveKakaoMember(createKakaoMemberDTO: KakaoMemberRequest): Long { + // 전화번호 중복 검사 + memberRepository.findByTelNo(createKakaoMemberDTO.telNo)?.let { + throw DuplicatedTelNoException(ErrorCode.DUPLICATED_TEL_NO) + } + + val member = Member( + telNo = createKakaoMemberDTO.telNo, + name = createKakaoMemberDTO.name, + memberShoppingActivity = MemberShoppingActivity.init(), + ) + + memberRepository.save(member) + + return member.memberId!! + } + @Transactional(readOnly = true) fun signUpValidCheck(createMemberDTO: CreateMemberRequest) { memberRepository.findByTelNo(createMemberDTO.telNo)?.let { diff --git a/src/main/kotlin/org/store/clothstar/order/service/OrderUserService.kt b/src/main/kotlin/org/store/clothstar/order/service/OrderUserService.kt index 51e100f..6873ef2 100644 --- a/src/main/kotlin/org/store/clothstar/order/service/OrderUserService.kt +++ b/src/main/kotlin/org/store/clothstar/order/service/OrderUserService.kt @@ -4,12 +4,11 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.web.server.ResponseStatusException import org.store.clothstar.common.error.ErrorCode import org.store.clothstar.common.error.exception.order.InsufficientStockException +import org.store.clothstar.common.error.exception.order.InvalidOrderStatusException import org.store.clothstar.common.error.exception.order.OrderNotFoundException import org.store.clothstar.member.domain.Address import org.store.clothstar.member.domain.Member @@ -73,10 +72,8 @@ class OrderUserService( // Map으로부터 Id, Entity를 가져오면서 주문상세 DTO 리스트 만들기 val orderDetailDTOList: List = orderDetails.map { - val product: Product = productMap[it.productId] - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found") - val item: Item = itemMap[it.itemId] - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Item not found") + val product: Product = productMap[it.productId]!! + val item: Item = itemMap[it.itemId]!! val brandName: String = seller.brandName OrderDetailDTO.from(it, item, product, brandName) } @@ -118,10 +115,8 @@ class OrderUserService( // Map으로부터 Id, Entity를 가져오면서 주문상세 DTO 리스트 만들기 val orderDetailDTOList: List = orderDetails.map { - val product: Product = productMap[it.productId] - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found") - val item: Item = itemMap[it.itemId] - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Item not found") + val product: Product = productMap[it.productId]!! + val item: Item = itemMap[it.itemId]!! val brandName: String = seller.brandName OrderDetailDTO.from(it, item, product, brandName) } @@ -197,7 +192,7 @@ class OrderUserService( } if (order.status != Status.CONFIRMED) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 입금된 상태에서는 추가 주문이 불가능합니다.") + throw InvalidOrderStatusException(ErrorCode.INVALID_ORDER_STATUS_CONFIRMED) } val orderDetail = addOrderDetailRequest.toOrderDetail(order, product, item) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f4a0673..6a63d05 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,6 +32,31 @@ spring: port: 6379 duration: 600 + #oauth2 + security: + oauth2: + client: + registration: + kakao: + client_id: ${kakao.client.id} + client_secret: ${kakao.client.secret} + redirect_uri: http://localhost:8080/auth/kakao/callback + scope: + - name + - email + - profile_image + authorization_grant_type: authorization_code + client_name: Kakao + client_authentication_method: post + provider: kakao + + provider: + kakao: + authorization_uri: https://kauth.kakao.com/oauth/authorize + token_uri: https://kauth.kakao.com/oauth/token + user_info_uri: https://kapi.kakao.com/v2/user/me + user_name_attribute: id + email.send: ${email.send} springdoc: diff --git a/src/main/resources/order.sql b/src/main/resources/order.sql index 260568b..6a8c8a0 100644 --- a/src/main/resources/order.sql +++ b/src/main/resources/order.sql @@ -77,6 +77,8 @@ from address; select * from member; select * +from account; +select * from seller; select * from category; \ No newline at end of file diff --git a/src/main/resources/static/images/kakao_login_medium_narrow.png b/src/main/resources/static/images/kakao_login_medium_narrow.png new file mode 100644 index 0000000..09bb358 Binary files /dev/null and b/src/main/resources/static/images/kakao_login_medium_narrow.png differ diff --git a/src/main/resources/templates/kakaoLogin.html b/src/main/resources/templates/kakaoLogin.html new file mode 100644 index 0000000..c679d04 --- /dev/null +++ b/src/main/resources/templates/kakaoLogin.html @@ -0,0 +1,19 @@ + + + + + + + KakaoLogin + + +
+

카카오 로그인

+ > + + + +
+ + diff --git a/src/test/kotlin/org/store/clothstar/common/JasyptConfigTest.kt b/src/test/kotlin/org/store/clothstar/common/JasyptConfigTest.kt index 7b19273..ba69761 100644 --- a/src/test/kotlin/org/store/clothstar/common/JasyptConfigTest.kt +++ b/src/test/kotlin/org/store/clothstar/common/JasyptConfigTest.kt @@ -11,11 +11,11 @@ class JasyptConfigTest { @Test fun jasypt() { - val url = "kGXTSlfxUWNbRoGuBwNRTJBETjMz04AChYMrwDeY3Cs=" + val url = "" val username = "" val password = "" - println(jasyptDecoding(url)) + println(jasyptEncoding(url)) println(jasyptEncoding(username)) println(jasyptEncoding(password)) } diff --git a/src/test/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationControllerTest.kt b/src/test/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationControllerTest.kt index 7db7d04..73c7da5 100644 --- a/src/test/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationControllerTest.kt +++ b/src/test/kotlin/org/store/clothstar/member/authentication/controller/AuthenticationControllerTest.kt @@ -1,6 +1,8 @@ package org.store.clothstar.member.authentication.controller import com.fasterxml.jackson.databind.ObjectMapper +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -10,9 +12,13 @@ import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import org.store.clothstar.common.config.redis.RedisUtil +import org.store.clothstar.kakaoLogin.service.KakaoLoginService +import org.store.clothstar.member.authentication.domain.SignUpType +import org.store.clothstar.member.dto.request.KakaoMemberRequest +import org.store.clothstar.member.dto.request.SignUpRequest import org.store.clothstar.member.util.CreateObject @SpringBootTest @@ -23,26 +29,29 @@ class AuthenticationControllerTest( @Autowired private val mockMvc: MockMvc, @Autowired private val objectMapper: ObjectMapper, @Autowired private val redisUtil: RedisUtil, + @Autowired private val kakaoLoginService: KakaoLoginService, ) { private val MEMBER_URL = "/v1/members" private val LOGIN_URL = "/v1/members/login" - @DisplayName("멤버 회원가입 통합테스트후 로그인 통합테스트") + @DisplayName("멤버 일반 회원가입 통합테스트후 로그인 통합테스트") @Test - fun signUpIntegrationTest() { + fun normalSignUpIntegrationTest() { //given //이메일과 인증번호로 redis 데이터 생성 val email = "test@naver.com" val certifyNum = redisUtil.createdCertifyNum() redisUtil.createRedisData(email, certifyNum) - //이메일과 인증번호로 create DTO 객체 생성 + // 이메일과 인증번호로 create DTO 객체 생성 val createMemberRequest = CreateObject.getCreateMemberRequest(email, certifyNum) - val requestBody = objectMapper.writeValueAsString(createMemberRequest) + val signUpRequest = SignUpRequest(createMemberRequest, null) + val requestBody = objectMapper.writeValueAsString(signUpRequest) - //when + // when val actions = mockMvc.perform( post(MEMBER_URL) + .param("signUpType", SignUpType.NORMAL.toString()) .contentType(MediaType.APPLICATION_JSON) .content(requestBody) ) @@ -63,4 +72,131 @@ class AuthenticationControllerTest( loginActions.andExpect(status().isOk) } + + @DisplayName("멤버 카카오 회원가입 통합테스트후 로그인 통합테스트") + @Test + fun kakaoSignUpIntegrationTest() { + val mockWebServer = MockWebServer() + mockWebServer.start() + + //given + //KakaoLoginService의 tokenUri와 userUri를 MockWebServer로 설정 + kakaoLoginService.tokenUri = mockWebServer.url("/token").toString() + kakaoLoginService.userUri = mockWebServer.url("/user").toString() + + // 액세스 토큰 반환에 대한 응답 설정 + val tokenResponse = """ + { + "token_type": "bearer", + "access_token": "test_access_token", + "expires_in": 3600, + "refresh_token": "test_refresh_token", + "refresh_token_expires_in": 36000, + "scope": "account_email" + } + """ + + //사용자 정보 반환에 대한 응답 설정 + val userInfoResponse = """ + { + "id": 123456789, + "kakao_account": { + "email": "test@example.com" + } + } + """ + + //토큰 응답 추가(첫 번째 응답) + mockWebServer.enqueue( + MockResponse() + .setBody(tokenResponse) + .addHeader("Content-Type", "application/json") + ) + + //사용자 정보 응답 추가(두 번째 응답) + mockWebServer.enqueue( + MockResponse() + .setBody(userInfoResponse) + .addHeader("Content-Type", "application/json") + ) + + //요청 DTO 생성 + val kakaoMemberRequest = KakaoMemberRequest( + name = "Test User", + telNo = "010-1234-5678", + email = null, + code = "test_code" + ) + + //requestBody 설정 + val signUpRequest = SignUpRequest(null, kakaoMemberRequest) + val requestBody = objectMapper.writeValueAsString(signUpRequest) + + //when + val actions = mockMvc.perform( + post(MEMBER_URL) + .param("signUpType", SignUpType.KAKAO.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + + //then + actions.andExpect(status().isCreated) + + mockWebServer.shutdown() + } + + @DisplayName("멤버 카카오 회원가입에서 요청DTO가 NULL일 경우 에러처리 테스트") + @Test + fun normalSignUpExceptionTest() { + //kakaoMemberRequest 요청 DTO 생성 + val kakaoMemberRequest = KakaoMemberRequest( + name = "Test User", + telNo = "010-1234-5678", + email = null, + code = "test_code" + ) + + // kakaoMemberRequest를 null로 맞춤 + val signUpRequest = SignUpRequest(null, kakaoMemberRequest) + val requestBody = objectMapper.writeValueAsString(signUpRequest) + + //when & then + mockMvc.perform( + post(MEMBER_URL) + .param("signUpType", SignUpType.NORMAL.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.errorCode").value(400)) + .andExpect(jsonPath("$.message").value("회원가입 시 회원 정보가 필요합니다.")) + } + + @DisplayName("멤버 카카오 회원가입에서 요청DTO가 NULL일 경우 에러처리 테스트") + @Test + fun kakaoSignUpExceptionTest() { + // createMemberRequest 생성을 위한 이메일과 인증번호로 redis 데이터 생성 + val email = "test@naver.com" + val certifyNum = redisUtil.createdCertifyNum() + redisUtil.createRedisData(email, certifyNum) + + // kakaoMemberRequest를 null로 맞춤 + val createMemberRequest = CreateObject.getCreateMemberRequest(email, certifyNum) + val signUpRequest = SignUpRequest(createMemberRequest, null) + val requestBody = objectMapper.writeValueAsString(signUpRequest) + + //when & then + mockMvc.perform( + post(MEMBER_URL) + .param("signUpType", SignUpType.KAKAO.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.errorCode").value(400)) + .andExpect(jsonPath("$.message").value("회원가입 시 회원 정보가 필요합니다.")) + } } \ No newline at end of file diff --git a/src/test/kotlin/org/store/clothstar/member/authentication/controller/MemberSignupValidTest.kt b/src/test/kotlin/org/store/clothstar/member/authentication/controller/MemberSignupValidTest.kt index 09ed7d5..d2012cd 100644 --- a/src/test/kotlin/org/store/clothstar/member/authentication/controller/MemberSignupValidTest.kt +++ b/src/test/kotlin/org/store/clothstar/member/authentication/controller/MemberSignupValidTest.kt @@ -16,8 +16,11 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional +import org.store.clothstar.common.config.redis.RedisUtil +import org.store.clothstar.member.authentication.domain.SignUpType import org.store.clothstar.member.dto.request.CreateMemberRequest import org.store.clothstar.member.dto.request.ModifyPasswordRequest +import org.store.clothstar.member.dto.request.SignUpRequest @SpringBootTest @AutoConfigureMockMvc @@ -26,6 +29,7 @@ import org.store.clothstar.member.dto.request.ModifyPasswordRequest class MemberSignupValidTest( @Autowired private val mockMvc: MockMvc, @Autowired private val objectMapper: ObjectMapper, + @Autowired private val redisUtil: RedisUtil, ) { private val MEMBER_URL = "/v1/members" @@ -41,19 +45,21 @@ class MemberSignupValidTest( telNo = "010-1234-1234", certifyNum = "gg" ) + val signUpRequest = SignUpRequest(createMemberRequest, null) + val requestBody = objectMapper.writeValueAsString(signUpRequest) - val requestBody = objectMapper.writeValueAsString(createMemberRequest) - - //when val actions = mockMvc.perform( post(MEMBER_URL) + .param("signUpType", SignUpType.NORMAL.toString()) .contentType(MediaType.APPLICATION_JSON) .content(requestBody) ) //then actions.andExpect(status().is4xxClientError()) - actions.andExpect(MockMvcResultMatchers.jsonPath("$.errorMap.password").value("비밀번호는 최소 8자 이상이어야 합니다.")) + actions.andExpect( + MockMvcResultMatchers.jsonPath("$.errorMap['createMemberRequest.password']").value("비밀번호는 최소 8자 이상이어야 합니다.") + ) } @DisplayName("회원가입시 이름은 필수 값이다.") @@ -68,12 +74,12 @@ class MemberSignupValidTest( telNo = "010-1234-1234", certifyNum = "gg" ) + val signUpRequest = SignUpRequest(createMemberRequest, null) + val requestBody = objectMapper.writeValueAsString(signUpRequest) - val requestBody = objectMapper.writeValueAsString(createMemberRequest) - - //when val actions = mockMvc.perform( post(MEMBER_URL) + .param("signUpType", SignUpType.NORMAL.toString()) .contentType(MediaType.APPLICATION_JSON) .content(requestBody) ) @@ -81,7 +87,9 @@ class MemberSignupValidTest( //then Assertions.assertThat(createMemberRequest.password.length).isLessThan(8) actions.andExpect(status().is4xxClientError()) - actions.andExpect(MockMvcResultMatchers.jsonPath("$.errorMap.name").value("이름은 비어 있을 수 없습니다.")) + actions.andExpect( + MockMvcResultMatchers.jsonPath("$.errorMap.['createMemberRequest.name']").value("이름은 비어 있을 수 없습니다.") + ) } @DisplayName("회원가입시 전화번호 양식이 지켜져야 한다.") @@ -96,12 +104,12 @@ class MemberSignupValidTest( telNo = "010", certifyNum = "gg" ) + val signUpRequest = SignUpRequest(createMemberRequest, null) + val requestBody = objectMapper.writeValueAsString(signUpRequest) - val requestBody = objectMapper.writeValueAsString(createMemberRequest) - - //when val actions = mockMvc.perform( post(MEMBER_URL) + .param("signUpType", SignUpType.NORMAL.toString()) .contentType(MediaType.APPLICATION_JSON) .content(requestBody) ) @@ -109,7 +117,9 @@ class MemberSignupValidTest( //then Assertions.assertThat(createMemberRequest.password.length).isLessThan(8) actions.andExpect(status().is4xxClientError()) - actions.andExpect(MockMvcResultMatchers.jsonPath("$.errorMap.telNo").value("유효하지 않은 전화번호 형식입니다.")) + actions.andExpect( + MockMvcResultMatchers.jsonPath("$.errorMap.['createMemberRequest.telNo']").value("유효하지 않은 전화번호 형식입니다.") + ) } @DisplayName("비밀번호 변경 요청시에도 비밀번호는 8자리 이상이여야 한다.") diff --git a/src/test/kotlin/org/store/clothstar/member/util/CreateObject.kt b/src/test/kotlin/org/store/clothstar/member/util/CreateObject.kt index c0dbd89..fa7459a 100644 --- a/src/test/kotlin/org/store/clothstar/member/util/CreateObject.kt +++ b/src/test/kotlin/org/store/clothstar/member/util/CreateObject.kt @@ -3,10 +3,7 @@ package org.store.clothstar.member.util import org.store.clothstar.member.domain.* import org.store.clothstar.member.domain.vo.AddressInfo import org.store.clothstar.member.domain.vo.MemberShoppingActivity -import org.store.clothstar.member.dto.request.CreateAddressRequest -import org.store.clothstar.member.dto.request.CreateMemberRequest -import org.store.clothstar.member.dto.request.CreateSellerRequest -import org.store.clothstar.member.dto.request.MemberLoginRequest +import org.store.clothstar.member.dto.request.* class CreateObject { companion object { @@ -33,6 +30,15 @@ class CreateObject { ) } + fun getKakaoMemberRequest(name: String, telNo: String, code: String): KakaoMemberRequest { + return KakaoMemberRequest( + name = name, + telNo = telNo, + code = code, + email = null, + ) + } + fun getMemberLoginRequest(): MemberLoginRequest { return MemberLoginRequest( email = email, diff --git a/src/test/kotlin/org/store/clothstar/order/OrderUserIntegrationTest.kt b/src/test/kotlin/org/store/clothstar/order/OrderUserIntegrationTest.kt index b90c9b1..42af404 100644 --- a/src/test/kotlin/org/store/clothstar/order/OrderUserIntegrationTest.kt +++ b/src/test/kotlin/org/store/clothstar/order/OrderUserIntegrationTest.kt @@ -13,8 +13,7 @@ import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import org.store.clothstar.category.repository.CategoryJpaRepository import org.store.clothstar.member.domain.Member @@ -198,6 +197,82 @@ class OrderUserIntegrationTest( assertEquals(1, savedOrder.orderDetails.size) } + @Test + @DisplayName("주문 생성시 상품재고가 0일경우 에러처리 테스트") + fun testCreateOrder_itemStockZeroException() { + //given + val member: Member = memberRepository.save(CreateOrderObject.getMember()) + val address = addressRepository.save(CreateOrderObject.getAddress(member)) + val category = categoryRepository.save(CreateOrderObject.getCategory()) + sellerRepository.save(CreateOrderObject.getSeller(member)) + val product = productRepository.save(CreateOrderObject.getProduct(member, category)) + val item = itemRepository.save(CreateOrderObject.getItem(product)).apply { + stock = 0 + } + + val createOrderRequest = CreateOrderRequest( + paymentMethod = PaymentMethod.CARD, + memberId = member.memberId!!, + addressId = address.addressId!! + ) + val createOrderDetailRequest = CreateOrderDetailRequest( + productId = product.productId!!, + itemId = item.itemId!!, + quantity = 1 + ) + val orderRequestWrapper = OrderRequestWrapper(createOrderRequest, createOrderDetailRequest) + val requestBody = objectMapper.writeValueAsString(orderRequestWrapper) + + //when & then + mockMvc.perform( + MockMvcRequestBuilders.post(ORDER_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.errorCode").value(400)) + .andExpect(jsonPath("$.message").value("품절된 상품입니다.")) + } + + @Test + @DisplayName("주문 생성시, 주문개수가 상품재고보다 많을 때 재고 부족 예외처리 테스트") + fun testCreateOrder_InsufficientStock_Exception() { + //given + val member: Member = memberRepository.save(CreateOrderObject.getMember()) + val address = addressRepository.save(CreateOrderObject.getAddress(member)) + val category = categoryRepository.save(CreateOrderObject.getCategory()) + sellerRepository.save(CreateOrderObject.getSeller(member)) + val product = productRepository.save(CreateOrderObject.getProduct(member, category)) + val item = itemRepository.save(CreateOrderObject.getItem(product)).apply { + stock = 1 + } + + val createOrderRequest = CreateOrderRequest( + paymentMethod = PaymentMethod.CARD, + memberId = member.memberId!!, + addressId = address.addressId!! + ) + val createOrderDetailRequest = CreateOrderDetailRequest( + productId = product.productId!!, + itemId = item.itemId!!, + quantity = 5 + ) + val orderRequestWrapper = OrderRequestWrapper(createOrderRequest, createOrderDetailRequest) + val requestBody = objectMapper.writeValueAsString(orderRequestWrapper) + + //when & then + mockMvc.perform( + MockMvcRequestBuilders.post(ORDER_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.errorCode").value(400)) + .andExpect(jsonPath("$.message").value("주문 개수가 상품 재고보다 더 많아 요청을 처리할 수 없습니다.")) + } + @Test @DisplayName("주문상세 추가") fun testAddOrderDetail() { @@ -268,7 +343,7 @@ class OrderUserIntegrationTest( //then actions.andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("주문이 정상적으로 취소되었습니다.")) - assertEquals(savedOrder!!.status,Status.CANCELED) + assertEquals(savedOrder!!.status, Status.CANCELED) } @DisplayName("주문 삭제 통합테스트") @@ -287,7 +362,7 @@ class OrderUserIntegrationTest( ) val savedOrder: Order? = orderRepository.findByIdOrNull(orderId) val deletedTime = LocalDateTime.now() - val timeDifference = ChronoUnit.SECONDS.between(deletedTime,savedOrder!!.deletedAt) + val timeDifference = ChronoUnit.SECONDS.between(deletedTime, savedOrder!!.deletedAt) //then actions.andExpect(status().isOk()) diff --git a/src/test/kotlin/org/store/clothstar/order/service/OrderSellerServiceTest.kt b/src/test/kotlin/org/store/clothstar/order/service/OrderSellerServiceTest.kt index 85f70cd..dc43454 100644 --- a/src/test/kotlin/org/store/clothstar/order/service/OrderSellerServiceTest.kt +++ b/src/test/kotlin/org/store/clothstar/order/service/OrderSellerServiceTest.kt @@ -5,22 +5,35 @@ import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension import io.mockk.justRun +import io.mockk.mockk import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException import org.store.clothstar.common.error.ErrorCode import org.store.clothstar.common.error.exception.order.OrderNotFoundException +import org.store.clothstar.member.domain.Address +import org.store.clothstar.member.domain.Member +import org.store.clothstar.member.domain.Seller +import org.store.clothstar.member.domain.vo.AddressInfo import org.store.clothstar.member.service.AddressService import org.store.clothstar.member.service.MemberService import org.store.clothstar.member.service.SellerService import org.store.clothstar.order.domain.Order -import org.store.clothstar.order.domain.vo.Status +import org.store.clothstar.order.domain.OrderDetail +import org.store.clothstar.order.domain.vo.* +import org.store.clothstar.order.dto.response.OrderResponse import org.store.clothstar.order.repository.OrderRepository +import org.store.clothstar.product.domain.Item +import org.store.clothstar.product.domain.Product import org.store.clothstar.product.service.ItemService import org.store.clothstar.product.service.ProductService +import java.time.LocalDateTime import kotlin.test.Test @ExtendWith(MockKExtension::class) @@ -46,28 +59,298 @@ class OrderSellerServiceTest { @MockK lateinit var productService: ProductService - @MockK - lateinit var order: Order + // 판매자 주문(CONFIRMED) 리스트 조회 + @Test + @DisplayName("판매자 주문(CONFIRMED) 리스트 조회 - 성공 테스트") + fun addOrderDetail_success_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { fixedPrice } returns 10000 + every { oneKindTotalPrice } returns 10000 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns 1 + every { orderDetailId } returns 1L + every { deletedAt } returns null + every { itemId } returns 1L + every { productId } returns 1L + } + + val item = mockk { + every { itemId } returns 1L + every { name } returns "상품옵션이름" + every { finalPrice } returns 10000 + } + + val product = mockk { + every { productId } returns 1L + every { name } returns "상품이름" + every { price } returns 1000 + } + + val member = mockk { + every { name } returns "수빈" + } + + val orderAddressInfo = mockk { + every { addressBasic } returns "address1" + every { addressDetail } returns "address2" + } + + val address = mockk
{ + every { addressInfo } returns orderAddressInfo + every { receiverName } returns "수빈" + every { telNo } returns "010-1111-1111" + every { deliveryRequest } returns "문앞" + } + + val seller = mockk { + every { brandName } returns "brandName" + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + every { payment } returns 8000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { memberId } returns 1L + every { addressId } returns 1L + every { createdAt } returns LocalDateTime.now() + every { status } returns Status.CONFIRMED + every { paymentMethod } returns PaymentMethod.CARD + every { totalPrice } returns orderTotalPrice + every { orderDetails } returns mutableListOf(orderDetail) + } + + //given + every { orderRepository.findConfirmedAndNotDeletedOrders() } returns listOf(order) + every { memberService.getMemberByMemberId(any()) } returns member + every { addressService.getAddressById(any()) } returns address + every { sellerService.getSellerById(any()) } returns seller + + val expectedorderResponse = OrderResponse.from(order, member, address) - val orderId = "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + // productIds, itemIds로부터 Product/Item 리스트 가져오기 + every { productService.findByProductIdIn(any()) } returns listOf(product) + every { itemService.findByIdIn(any()) } returns listOf(item) + + // 주문상세 DTO 리스트 만들기 + val orderDetailDTOs = listOf(OrderDetailDTO.from(orderDetail, item, product, seller.brandName)) + + // 응답 DTO에 주문상세 DTO 리스트 추가 + expectedorderResponse.updateOrderDetailList(orderDetailDTOs) + + //when + val orderReponse = orderSellerService.getConfirmedOrders() + + //then + assertThat(orderReponse).usingRecursiveComparison().isEqualTo(listOf(expectedorderResponse)) + verify(exactly = 1) { orderRepository.findConfirmedAndNotDeletedOrders() } + verify(exactly = 1) { memberService.getMemberByMemberId(any()) } + verify(exactly = 1) { addressService.getAddressById(any()) } + verify(exactly = 1) { sellerService.getSellerById(any()) } + verify(exactly = 1) { productService.findByProductIdIn(any()) } + verify(exactly = 1) { itemService.findByIdIn(any()) } + } + + @Test + @DisplayName("판매자 주문(CONFIRMED) 리스트 조회 - productId가 NULL일 경우 예외처리 테스트") + fun addOrderDetail_productIdNullException_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { fixedPrice } returns 10000 + every { oneKindTotalPrice } returns 10000 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns 1 + every { orderDetailId } returns 1L + every { deletedAt } returns null + every { itemId } returns 1L + every { productId } returns 1L + } + + val item = mockk { + every { itemId } returns 1L + every { name } returns "상품옵션이름" + every { finalPrice } returns 10000 + } + + val product = mockk { + every { productId } returns 1L + every { name } returns "상품이름" + every { price } returns 1000 + } + + val member = mockk { + every { name } returns "수빈" + } + + val orderAddressInfo = mockk { + every { addressBasic } returns "address1" + every { addressDetail } returns "address2" + } + + val address = mockk
{ + every { addressInfo } returns orderAddressInfo + every { receiverName } returns "수빈" + every { telNo } returns "010-1111-1111" + every { deliveryRequest } returns "문앞" + } + + val seller = mockk { + every { brandName } returns "brandName" + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + every { payment } returns 8000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { memberId } returns 1L + every { addressId } returns 1L + every { createdAt } returns LocalDateTime.now() + every { status } returns Status.CONFIRMED + every { paymentMethod } returns PaymentMethod.CARD + every { totalPrice } returns orderTotalPrice + every { orderDetails } returns mutableListOf(orderDetail) + } + + //given + every { orderRepository.findConfirmedAndNotDeletedOrders() } returns listOf(order) + every { memberService.getMemberByMemberId(any()) } returns member + every { addressService.getAddressById(any()) } returns address + every { sellerService.getSellerById(any()) } returns seller + + //`productService.findByProductIdIn()` 호출 시 빈 리스트 반환 설정 + every { productService.findByProductIdIn(any()) } returns listOf() + every { itemService.findByIdIn(any()) } returns listOf() + + //when + val exception = assertThrows { + orderSellerService.getConfirmedOrders() + } + + //then + assertThat(exception.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(exception.reason).isEqualTo("Product not found") + } + + @Test + @DisplayName("판매자 주문(CONFIRMED) 리스트 조회 - itemId가 NULL일 경우 예외처리 테스트") + fun addOrderDetail_itemIdNullException_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { fixedPrice } returns 10000 + every { oneKindTotalPrice } returns 10000 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns 1 + every { orderDetailId } returns 1L + every { deletedAt } returns null + every { itemId } returns 1L + every { productId } returns 1L + } + + val item = mockk { + every { itemId } returns 1L + every { name } returns "상품옵션이름" + every { finalPrice } returns 10000 + } + + val product = mockk { + every { productId } returns 1L + every { name } returns "상품이름" + every { price } returns 1000 + } + + val member = mockk { + every { name } returns "수빈" + } + + val orderAddressInfo = mockk { + every { addressBasic } returns "address1" + every { addressDetail } returns "address2" + } + + val address = mockk
{ + every { addressInfo } returns orderAddressInfo + every { receiverName } returns "수빈" + every { telNo } returns "010-1111-1111" + every { deliveryRequest } returns "문앞" + } + + val seller = mockk { + every { brandName } returns "brandName" + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + every { payment } returns 8000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { memberId } returns 1L + every { addressId } returns 1L + every { createdAt } returns LocalDateTime.now() + every { status } returns Status.CONFIRMED + every { paymentMethod } returns PaymentMethod.CARD + every { totalPrice } returns orderTotalPrice + every { orderDetails } returns mutableListOf(orderDetail) + } + + //given + every { orderRepository.findConfirmedAndNotDeletedOrders() } returns listOf(order) + every { memberService.getMemberByMemberId(any()) } returns member + every { addressService.getAddressById(any()) } returns address + every { sellerService.getSellerById(any()) } returns seller + + //`itemService.findByIdIn()` 호출 시 빈 리스트 반환 설정 + every { productService.findByProductIdIn(any()) } returns listOf(product) + every { itemService.findByIdIn(any()) } returns listOf() + + //when + val exception = assertThrows { + orderSellerService.getConfirmedOrders() + } + + //then + assertThat(exception.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(exception.reason).isEqualTo("Item not found") + } // 판매자 주문 승인 - approveOrder @Test @DisplayName("판매자 주문 승인 - 성공 테스트") fun approveOrder_success_test() { //given - every { order.status } returns Status.CONFIRMED - every { orderRepository.findByIdOrNull(orderId) } returns order + val order = mockk { + every { status } returns Status.CONFIRMED + } + every { orderRepository.findByIdOrNull(any()) } returns order justRun { order.validateForStatusCONFIRMEDAndDeletedAt() } every { order.updateStatus(Status.PROCESSING) } answers { every { order.status } returns Status.PROCESSING } //when - orderSellerService.approveOrder(orderId) + orderSellerService.approveOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") //then - verify(exactly = 1) { orderRepository.findByIdOrNull(orderId) } + verify(exactly = 1) { orderRepository.findByIdOrNull(any()) } verify(exactly = 1) { order.validateForStatusCONFIRMEDAndDeletedAt() } verify(exactly = 1) { order.updateStatus(Status.PROCESSING) } assertEquals(Status.PROCESSING, order.status) @@ -77,12 +360,14 @@ class OrderSellerServiceTest { @DisplayName("판매자 주문 승인 - 주문번호가 존재하지 않을 때 예외처리 테스트") fun approveOrder_orderNotFound_exception_test() { //given - every { order.status } returns Status.CONFIRMED - every { orderRepository.findByIdOrNull(orderId) } returns null + val order = mockk { + every { status } returns Status.CONFIRMED + } + every { orderRepository.findByIdOrNull(any()) } returns null //when & then val exception = assertThrows { - orderSellerService.approveOrder(orderId) + orderSellerService.approveOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") } assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) } @@ -92,18 +377,20 @@ class OrderSellerServiceTest { @DisplayName("판매자 주문 취소 - 성공 테스트") fun cancelOrder_success_test() { //given - every { order.status } returns Status.CONFIRMED - every { orderRepository.findByIdOrNull(orderId) } returns order + val order = mockk { + every { status } returns Status.CONFIRMED + } + every { orderRepository.findByIdOrNull(any()) } returns order justRun { order.validateForStatusCONFIRMEDAndDeletedAt() } every { order.updateStatus(Status.CANCELED) } answers { every { order.status } returns Status.CANCELED } //when - orderSellerService.cancelOrder(orderId) + orderSellerService.cancelOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") //then - verify(exactly = 1) { orderRepository.findByIdOrNull(orderId) } + verify(exactly = 1) { orderRepository.findByIdOrNull(any()) } verify(exactly = 1) { order.validateForStatusCONFIRMEDAndDeletedAt() } verify(exactly = 1) { order.updateStatus(Status.CANCELED) } assertEquals(Status.CANCELED, order.status) @@ -113,12 +400,14 @@ class OrderSellerServiceTest { @DisplayName("판매자 주문 취소 - 주문번호가 존재하지 않을 때 예외처리 테스트") fun cancelOrder_orderNotFound_exception_test() { //given - every { order.status } returns Status.CONFIRMED - every { orderRepository.findByIdOrNull(orderId) } returns null + val order = mockk { + every { status } returns Status.CONFIRMED + } + every { orderRepository.findByIdOrNull(any()) } returns null //when & then val exception = assertThrows { - orderSellerService.cancelOrder(orderId) + orderSellerService.cancelOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") } assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) } diff --git a/src/test/kotlin/org/store/clothstar/order/service/OrderUserServiceTest.kt b/src/test/kotlin/org/store/clothstar/order/service/OrderUserServiceTest.kt index c1294fc..8c52392 100644 --- a/src/test/kotlin/org/store/clothstar/order/service/OrderUserServiceTest.kt +++ b/src/test/kotlin/org/store/clothstar/order/service/OrderUserServiceTest.kt @@ -1,60 +1,669 @@ package org.store.clothstar.order.service -import io.kotest.core.spec.style.BehaviorSpec -import io.kotest.matchers.shouldBe import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension import io.mockk.justRun import io.mockk.mockk import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.* import org.springframework.data.repository.findByIdOrNull +import org.store.clothstar.common.error.ErrorCode +import org.store.clothstar.common.error.exception.order.InsufficientStockException +import org.store.clothstar.common.error.exception.order.InvalidOrderStatusException +import org.store.clothstar.common.error.exception.order.OrderNotFoundException +import org.store.clothstar.member.domain.Address +import org.store.clothstar.member.domain.Member +import org.store.clothstar.member.domain.Seller +import org.store.clothstar.member.domain.vo.AddressInfo import org.store.clothstar.member.service.AddressService import org.store.clothstar.member.service.MemberService import org.store.clothstar.member.service.SellerService import org.store.clothstar.order.domain.Order -import org.store.clothstar.order.domain.vo.Status +import org.store.clothstar.order.domain.OrderDetail +import org.store.clothstar.order.domain.vo.* +import org.store.clothstar.order.dto.request.AddOrderDetailRequest +import org.store.clothstar.order.dto.request.OrderRequestWrapper +import org.store.clothstar.order.dto.response.OrderResponse import org.store.clothstar.order.repository.OrderDetailRepository import org.store.clothstar.order.repository.OrderRepository import org.store.clothstar.order.service.OrderSave.OrderSaveFacade +import org.store.clothstar.product.domain.Item +import org.store.clothstar.product.domain.Product import org.store.clothstar.product.service.ItemService import org.store.clothstar.product.service.ProductService +import java.time.LocalDateTime +import kotlin.test.Test @ExtendWith(MockKExtension::class) -class OrderUserServiceTest : BehaviorSpec({ - - val orderRepository: OrderRepository = mockk() - val orderDetailRepository: OrderDetailRepository = mockk() - val memberService: MemberService = mockk() - val sellerService: SellerService = mockk() - val addressService: AddressService = mockk() - val itemService: ItemService = mockk() - val productService: ProductService = mockk() - val orderSaveFacade: OrderSaveFacade = mockk() - val order: Order = mockk() - - val orderService = OrderUserService( - orderSaveFacade, orderRepository, orderDetailRepository, memberService, addressService, sellerService, - productService, itemService - ) - - val orderId = "4b1a17b5-45f0-455a-a5e3-2c863de18b05" - - Given("구매자 구매 확정 - 성공 테스트") { - every { order.status } returns Status.DELIVERED - every { orderRepository.findByIdOrNull(orderId) } returns order +class OrderUserServiceTest { + @InjectMockKs + lateinit var orderUserService: OrderUserService + + @MockK + lateinit var orderRepository: OrderRepository + + @MockK + lateinit var orderDetailRepository: OrderDetailRepository + + @MockK + lateinit var orderSaveFacade: OrderSaveFacade + + @MockK + lateinit var memberService: MemberService + + @MockK + lateinit var sellerService: SellerService + + @MockK + lateinit var addressService: AddressService + + @MockK + lateinit var itemService: ItemService + + @MockK + lateinit var productService: ProductService + + @Test + @DisplayName("단일 주문 조회 - 성공 테스트") + fun getOrder_success_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { fixedPrice } returns 10000 + every { oneKindTotalPrice } returns 10000 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns 1 + every { orderDetailId } returns 1L + every { deletedAt } returns null + every { itemId } returns 1L + every { productId } returns 1L + } + + val item = mockk { + every { itemId } returns 1L + every { name } returns "상품옵션이름" + every { finalPrice } returns 10000 + } + + val product = mockk { + every { productId } returns 1L + every { name } returns "상품이름" + every { price } returns 1000 + } + + val member = mockk { + every { name } returns "수빈" + } + + val orderAddressInfo = mockk { + every { addressBasic } returns "address1" + every { addressDetail } returns "address2" + } + + val address = mockk
{ + every { addressInfo } returns orderAddressInfo + every { receiverName } returns "수빈" + every { telNo } returns "010-1111-1111" + every { deliveryRequest } returns "문앞" + } + + val seller = mockk { + every { brandName } returns "brandName" + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + every { payment } returns 8000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { memberId } returns 1L + every { addressId } returns 1L + every { createdAt } returns LocalDateTime.now() + every { status } returns Status.CONFIRMED + every { paymentMethod } returns PaymentMethod.CARD + every { totalPrice } returns orderTotalPrice + every { orderDetails } returns mutableListOf(orderDetail) + } + + // OrderDetailDTO 생성 + val orderDetailDTOs = listOf(OrderDetailDTO.from(orderDetail, item, product, seller.brandName)) + + // 기대하는 OrderResponse 생성 + val expectedOrderResponse = OrderResponse.from(order, member, address).apply { + updateOrderDetailList(orderDetailDTOs) + } + + // Mock 동작 설정 + every { orderRepository.findByOrderIdAndDeletedAtIsNull(any()) } returns order + every { memberService.getMemberByMemberId(any()) } returns member + every { addressService.getAddressById((any())) } returns address + every { sellerService.getSellerById((any())) } returns seller + every { productService.findByProductIdIn(any()) } returns listOf(product) + every { itemService.findByIdIn(any()) } returns listOf(item) + + // when + val orderResponse = orderUserService.getOrder(order.orderId) + + // then + assertThat(orderResponse).usingRecursiveComparison().isEqualTo(expectedOrderResponse) + verify(exactly = 1) { orderRepository.findByOrderIdAndDeletedAtIsNull(any()) } + verify(exactly = 1) { memberService.getMemberByMemberId(any()) } + verify(exactly = 1) { itemService.findByIdIn(any()) } + verify(exactly = 1) { productService.findByProductIdIn(any()) } + } + + @Test + @DisplayName("단일 주문 조회 - 주문번호가 존재하지 않을 때 예외처리 테스트") + fun getOrder_orderNotFound_exception_test() { + //given + every { orderRepository.findByOrderIdAndDeletedAtIsNull(any()) } returns null + + //when & then + val exception = assertThrows { + orderUserService.getOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") + } + assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) + } + + @Test + @DisplayName("전체 주문 페이징 조회 offset 방식 - 성공 테스트") + fun getAllOrderOffsetPaging_success_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { fixedPrice } returns 10000 + every { oneKindTotalPrice } returns 10000 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns 1 + every { orderDetailId } returns 1L + every { deletedAt } returns null + every { itemId } returns 1L + every { productId } returns 1L + } + + val item = mockk { + every { itemId } returns 1L + every { name } returns "상품옵션이름" + every { finalPrice } returns 10000 + } + + val product = mockk { + every { productId } returns 1L + every { name } returns "상품이름" + every { price } returns 1000 + } + + val member = mockk { + every { name } returns "수빈" + } + + val orderAddressInfo = mockk { + every { addressBasic } returns "address1" + every { addressDetail } returns "address2" + } + + val address = mockk
{ + every { addressInfo } returns orderAddressInfo + every { receiverName } returns "수빈" + every { telNo } returns "010-1111-1111" + every { deliveryRequest } returns "문앞" + } + + val seller = mockk { + every { brandName } returns "brandName" + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + every { payment } returns 8000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { memberId } returns 1L + every { addressId } returns 1L + every { createdAt } returns LocalDateTime.now() + every { status } returns Status.CONFIRMED + every { paymentMethod } returns PaymentMethod.CARD + every { totalPrice } returns orderTotalPrice + every { orderDetails } returns mutableListOf(orderDetail) + } + + // given + val pageable: Pageable = PageRequest.of(0, 10) + val orders: Page = PageImpl(listOf(order)) + every { orderRepository.findAll(pageable) } returns orders + + // order 관련 member, address, seller 불러오기 + every { memberService.getMemberByMemberId(any()) } returns member + every { addressService.getAddressById(any()) } returns address + every { sellerService.getSellerById(any()) } returns seller + + // 응답 DTO 생성(주문상세 리스트는 빈 상태) + val expectedOrderResponse = OrderResponse.from(order, member, address) + + every { productService.findByProductIdIn(any()) } returns listOf(product) + every { itemService.findByIdIn(any()) } returns listOf(item) + + // 주문상세 DTO 리스트 만들기 + val orderDetailDTOs: List = + listOf(OrderDetailDTO.from(orderDetail, item, product, seller.brandName)) + + // 응답 DTO 생성 및 주문상세 DTO 리스트 추가 + expectedOrderResponse.updateOrderDetailList(orderDetailDTOs) + + // when + val orderResponses: Page = orderUserService.getAllOrderOffsetPaging(pageable) + + // then + assertThat(orderResponses.content).usingRecursiveComparison().isEqualTo(listOf(expectedOrderResponse)) + verify(exactly = 1) { orderRepository.findAll(pageable) } + verify(exactly = 1) { memberService.getMemberByMemberId(any()) } + verify(exactly = 1) { addressService.getAddressById(any()) } + verify(exactly = 1) { sellerService.getSellerById(any()) } + verify(exactly = 1) { productService.findByProductIdIn(any()) } + verify(exactly = 1) { itemService.findByIdIn(any()) } + } + + @Test + @DisplayName("전체 주문 페이징 조회 slice 방식 - 성공 테스트") + fun getAllOrderSlicePaging_success_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { fixedPrice } returns 10000 + every { oneKindTotalPrice } returns 10000 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns 1 + every { orderDetailId } returns 1L + every { deletedAt } returns null + every { itemId } returns 1L + every { productId } returns 1L + } + + val item = mockk { + every { itemId } returns 1L + every { name } returns "상품옵션이름" + every { finalPrice } returns 10000 + } + + val product = mockk { + every { productId } returns 1L + every { name } returns "상품이름" + every { price } returns 1000 + } + + val member = mockk { + every { name } returns "수빈" + } + + val orderAddressInfo = mockk { + every { addressBasic } returns "address1" + every { addressDetail } returns "address2" + } + + val address = mockk
{ + every { addressInfo } returns orderAddressInfo + every { receiverName } returns "수빈" + every { telNo } returns "010-1111-1111" + every { deliveryRequest } returns "문앞" + } + + val seller = mockk { + every { brandName } returns "brandName" + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + every { payment } returns 8000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { memberId } returns 1L + every { addressId } returns 1L + every { createdAt } returns LocalDateTime.now() + every { status } returns Status.CONFIRMED + every { paymentMethod } returns PaymentMethod.CARD + every { totalPrice } returns orderTotalPrice + every { orderDetails } returns mutableListOf(orderDetail) + } + + // given + val pageable: Pageable = PageRequest.of(0, 10) + val orders: Page = PageImpl(listOf(order)) + every { orderRepository.findAllByOrderByOrderIdDesc(pageable) } returns orders + + // order 관련 member, address, seller 불러오기 + every { memberService.getMemberByMemberId(any()) } returns member + every { addressService.getAddressById(any()) } returns address + every { sellerService.getSellerById(any()) } returns seller + + // 응답 DTO 생성(주문상세 리스트는 빈 상태) + val expectedOrderResponse = OrderResponse.from(order, member, address) + + // productIds, itemIds로부터 Product/Item 리스트 가져오기 + every { productService.findByProductIdIn(any()) } returns listOf(product) + every { itemService.findByIdIn(any()) } returns listOf(item) + + // 주문상세 DTO 리스트 만들기 + val orderDetailDTOs: List = + listOf(OrderDetailDTO.from(orderDetail, item, product, seller.brandName)) + + // 응답 DTO 생성 및 주문상세 DTO 리스트 추가 + expectedOrderResponse.updateOrderDetailList(orderDetailDTOs) + + // when + val orderResponses: Slice = orderUserService.getAllOrderSlicePaging(pageable) + + // then + assertThat(orderResponses.content).usingRecursiveComparison().isEqualTo(listOf(expectedOrderResponse)) + verify(exactly = 1) { orderRepository.findAllByOrderByOrderIdDesc(pageable) } + verify(exactly = 1) { memberService.getMemberByMemberId(any()) } + verify(exactly = 1) { addressService.getAddressById(any()) } + verify(exactly = 1) { sellerService.getSellerById(any()) } + verify(exactly = 1) { productService.findByProductIdIn(any()) } + verify(exactly = 1) { itemService.findByIdIn(any()) } + } + + @Test + @DisplayName("주문 저장") + fun saveOrder() { + //given + val orderRequestWrapper = mockk() + every { orderUserService.saveOrder(orderRequestWrapper) } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + + //when + orderUserService.saveOrder(orderRequestWrapper) + + //then + verify(exactly = 1) { orderSaveFacade.saveOrder(orderRequestWrapper) } + } + + @Test + @DisplayName("주문 상세 추가 - 성공 테스트") + fun addOrderDetail_success_test() { + // Mock 객체 생성 및 설정 + val orderPrice = mockk { + every { oneKindTotalPrice } returns 10000 + } + + val item = mockk { + every { itemId } returns 1L + every { stock } returns 10 + } + + val product = mockk { + every { productId } returns 1L + every { price } returns 1000 + } + + val orderTotalPrice = mockk { + every { shipping } returns 3000 + every { products } returns 5000 + } + + val order = mockk { + every { orderId } returns "4b1a17b5-45f0-455a-a5e3-2c863de18b05" + every { status } returns Status.CONFIRMED + every { totalPrice } returns orderTotalPrice + every { totalPrice.products } returns 5000 + every { totalPrice.shipping } returns 3000 + } + + val addOrderDetailRequest = mockk { + every { orderId } returns order.orderId + every { productId } returns 1L + every { itemId } returns 1L + every { quantity } returns 1 + } + + val orderDetail = mockk { + every { price } returns orderPrice + every { quantity } returns addOrderDetailRequest.quantity + every { orderDetailId } returns 1L + } + + // 요청 DTO와 관련된 order, product, item 불러오기 + every { orderRepository.findByIdOrNull(any()) } returns order + every { productService.getProductById(any()) } returns product + every { itemService.getItemById(any()) } returns item + + every { addOrderDetailRequest.toOrderDetail(order, product, item) } returns orderDetail + every { orderDetailRepository.save(orderDetail) } returns orderDetail + + justRun { order.addOrderDetail(any()) } + justRun { order.totalPrice.updatePrices(any(), any()) } + justRun { item.updateStock(any()) } + justRun { orderUserService.updateProductStock(item, orderDetail.quantity) } + + //when + val orderDetailId: Long = orderUserService.addOrderDetail(addOrderDetailRequest) + + //then + assertEquals(orderDetailId, orderDetail.orderDetailId) + verify(exactly = 1) { orderRepository.findByIdOrNull(any()) } + verify(exactly = 1) { orderDetailRepository.save(orderDetail) } + } + + @Test + @DisplayName("주문 상세 추가 - 주문번호가 존재하지 않을 때 예외처리 테스트") + fun addOrderDetail_orderNotFound_exception_test() { + //given + val addOrderDetailRequest = AddOrderDetailRequest( + orderId = "4b1a17b5-45f0-455a-a5e3-2c863de18b05", + productId = 1L, + itemId = 1L, + quantity = 1, + ) + every { orderRepository.findByIdOrNull(any()) } returns null + + //when & then + val exception = assertThrows { + orderUserService.addOrderDetail(addOrderDetailRequest) + } + assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) + } + + @Test + @DisplayName("주문 상세 추가 - 주문수량이 재고보다 더 많을 때 예외처리 테스트") + fun addOrderDetail_insufficientStock_exception_test() { + //given + val addOrderDetailRequest = AddOrderDetailRequest( + orderId = "4b1a17b5-45f0-455a-a5e3-2c863de18b05", + productId = 1L, + itemId = 1L, + quantity = 100, + ) + val item = mockk { + every { stock } returns 10 + } + val product = mockk {} + val order = mockk {} + + every { orderRepository.findByIdOrNull(any()) } returns order + every { productService.getProductById(any()) } returns product + every { itemService.getItemById(any()) } returns item + + //when & then + val exception = assertThrows { + orderUserService.addOrderDetail(addOrderDetailRequest) + } + assertEquals(ErrorCode.INSUFFICIENT_STOCK, exception.errorCode) + } + + @Test + @DisplayName("주문 상세 추가 - 주문상태가 CONFIRMED가 아닐 때 예외처리 테스트") + fun addOrderDetail_invalidOrderStatusException_exception_test() { + //given + val addOrderDetailRequest = AddOrderDetailRequest( + orderId = "4b1a17b5-45f0-455a-a5e3-2c863de18b05", + productId = 1L, + itemId = 1L, + quantity = 1, + ) + val item = mockk { + every { stock } returns 10 + } + val product = mockk {} + val order = mockk { + every { status } returns Status.DELIVERED + } + + every { orderRepository.findByIdOrNull(any()) } returns order + every { productService.getProductById(any()) } returns product + every { itemService.getItemById(any()) } returns item + + //when & then + val exception = assertThrows { + orderUserService.addOrderDetail(addOrderDetailRequest) + } + assertEquals(ErrorCode.INVALID_ORDER_STATUS_CONFIRMED, exception.errorCode) + } + + // 구매자 구매 확정 - completeOrder + @Test + @DisplayName("구매자 구매 확정 - 성공 테스트") + fun completeOrder_success_test() { + //given + val order = mockk { + every { status } returns Status.DELIVERED + } + every { orderRepository.findByIdOrNull(any()) } returns order justRun { order.validateForStatusDELIVEREDAndDeletedAt() } every { order.updateStatus(Status.COMPLETED) } answers { every { order.status } returns Status.COMPLETED } - When("orderId가 존재하면서 정상 요청이 오면") { - orderService.completeOrder(orderId) - Then("정상 응답") { - verify(exactly = 1) { orderRepository.findByIdOrNull(orderId) } - verify(exactly = 1) { order.validateForStatusDELIVEREDAndDeletedAt() } - verify(exactly = 1) { order.updateStatus(Status.COMPLETED) } - order.status shouldBe Status.COMPLETED - } + + //when + orderUserService.completeOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") + + //then + verify(exactly = 1) { orderRepository.findByIdOrNull(any()) } + verify(exactly = 1) { order.validateForStatusDELIVEREDAndDeletedAt() } + verify(exactly = 1) { order.updateStatus(Status.COMPLETED) } + assertEquals(Status.COMPLETED, order.status) + } + + @Test + @DisplayName("구매자 구매 확정 - 주문번호가 존재하지 않을 때 예외처리 테스트") + fun completeOrder_orderNotFound_exception_test() { + //given + val order = mockk { + every { status } returns Status.DELIVERED + } + every { orderRepository.findByIdOrNull(any()) } returns null + + //when & then + val exception = assertThrows { + orderUserService.completeOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") + } + assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) + } + + // 구매자 주문 취소 - cancelOrder + @Test + @DisplayName("구매자 주문 취소 - 성공 테스트") + fun cancelOrder_success_test() { + //given + val order = mockk { + every { status } returns Status.CONFIRMED + } + every { orderRepository.findByIdOrNull(any()) } returns order + justRun { order.validateForStatusCONFIRMEDAndDeletedAt() } + every { order.updateStatus(Status.CANCELED) } answers { + every { order.status } returns Status.CANCELED + } + + //when + orderUserService.cancelOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") + + //then + verify(exactly = 1) { orderRepository.findByIdOrNull(any()) } + verify(exactly = 1) { order.validateForStatusCONFIRMEDAndDeletedAt() } + verify(exactly = 1) { order.updateStatus(Status.CANCELED) } + assertEquals(Status.CANCELED, order.status) + } + + @Test + @DisplayName("구매자 주문 취소 - 주문번호가 존재하지 않을 때 예외처리 테스트") + fun cancelOrder_orderNotFound_exception_test() { + //given + val order = mockk { + every { status } returns Status.CONFIRMED + } + every { orderRepository.findByIdOrNull(any()) } returns null + + //when & then + val exception = assertThrows { + orderUserService.cancelOrder("4b1a17b5-45f0-455a-a5e3-2c863de18b05") + } + assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) + } + + // 주문 삭제 - updateDeleteAt + @Test + @DisplayName("주문 삭제 - 성공 테스트") + fun updateDeleteAt_success_test() { + //given + val order = mockk { + every { status } returns Status.CONFIRMED + } + val orderDetail = mockk {} + + every { orderRepository.findByIdOrNull(any()) } returns order + justRun { order.validateForDeletedAt() } + every { orderDetailRepository.findOrderDetailListByOrderId(any()) } returns listOf(orderDetail) + every { orderDetail.updateDeletedAt() } answers { + every { orderDetail.deletedAt } returns LocalDateTime.now() + } + every { order.updateDeletedAt() } answers { + every { order.deletedAt } returns LocalDateTime.now() + } + + //when + orderUserService.updateDeleteAt("4b1a17b5-45f0-455a-a5e3-2c863de18b05") + + //then + verify(exactly = 1) { orderRepository.findByIdOrNull(any()) } + verify(exactly = 1) { order.validateForDeletedAt() } + verify(exactly = 1) { orderDetailRepository.findOrderDetailListByOrderId(any()) } + verify(exactly = 1) { orderDetail.updateDeletedAt() } + verify(exactly = 1) { order.updateDeletedAt() } + + assertNotNull(order.deletedAt) + assertNotNull(orderDetail.deletedAt) + } + + @Test + @DisplayName("주문 삭제 - 주문번호가 존재하지 않을 때 예외처리 테스트") + fun updateDeleteAt_orderNotFound_exception_test() { + //given + every { orderRepository.findByIdOrNull(any()) } returns null + + //when & then + val exception = assertThrows { + orderUserService.updateDeleteAt("4b1a17b5-45f0-455a-a5e3-2c863de18b05") } + assertEquals(ErrorCode.NOT_FOUND_ORDER, exception.errorCode) } -}) \ No newline at end of file +} \ No newline at end of file