diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml new file mode 100644 index 00000000..d46037e7 --- /dev/null +++ b/.github/workflows/github-actions.yml @@ -0,0 +1,146 @@ + +# github repository Actions 페이지에 나타낼 이름 +name: Dotoriham CI/CD + +# event trigger +on: + push: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + CI-CD: + runs-on: ubuntu-latest + steps: + + ## jdk setting + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' # https://github.com/actions/setup-java + + ## gradle caching + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + ## create application-prod.properties + - name: create application-prod.properties + if: contains(github.ref, 'main') + run: | + cd ./src/main/resources + touch ./application-prod.properties + echo "${{ secrets.PROPERTIES_PROD }}" > ./application-prod.properties + shell: bash + + ## create application-dev.properties + - name: create application-dev.properties + if: contains(github.ref, 'develop') + run: | + cd ./src/main/resources + touch ./application-dev.properties + echo "${{ secrets.PROPERTIES_DEV }}" > ./application-dev.properties + shell: bash + + ## create firebase-key.json + - name: create firebase key + run: | + cd ./src/main/resources + ls -a . + touch ./firebase-service-key.json + echo "${{ secrets.FIREBASE_KEY }}" > ./firebase-service-key.json + shell: bash + + ## gradle build + - name: Build with Gradle + run: ./gradlew build -x test -x ktlintCheck -x ktlintTestSourceSetCheck -x ktlintMainSourceSetCheck -x ktlintKotlinScriptCheck + + ## docker build & push to production + - name: Docker build & push to prod + if: contains(github.ref, 'main') + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -f Dockerfile-prod -t ${{ secrets.DOCKER_REPO }}/dotoriham-prod . + docker push ${{ secrets.DOCKER_REPO }}/dotoriham-prod + + ## docker build & push to develop + - name: Docker build & push to dev + if: contains(github.ref, 'develop') + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_REPO }}/dotoriham-dev . + docker push ${{ secrets.DOCKER_REPO }}/dotoriham-dev + + ## deploy to production + - name: Deploy to prod + uses: appleboy/ssh-action@master + id: deploy-prod + if: contains(github.ref, 'main') + with: + host: ${{ secrets.HOST_PROD }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + port: 22 + script: | + sudo docker pull ${{ secrets.DOCKER_REPO }}/dotoriham-prod + docker-compose up -d + docker image prune -f + + ## deploy to develop + - name: Deploy to dev + uses: appleboy/ssh-action@master + id: deploy-dev + if: contains(github.ref, 'develop') + with: + host: ${{ secrets.HOST_DEV }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + port: 22 + #key: ${{ secrets.PRIVATE_KEY }} + script: | + sudo docker pull ${{ secrets.DOCKER_REPO }}/dotoriham-dev + docker-compose up -d + docker image prune -f + + ## time + current-time: + needs: CI-CD + runs-on: ubuntu-latest + steps: + - name: Get Current Time + uses: 1466587594/get-current-time@v2 + id: current-time + with: + format: YYYY-MM-DDTHH:mm:ss + utcOffset: "+09:00" # 기준이 UTC이기 때문에 한국시간인 KST를 맞추기 위해 +9시간 추가 + + - name: Print Current Time + run: echo "Current Time=${{steps.current-time.outputs.formattedTime}}" # current-time 에서 지정한 포맷대로 현재 시간 출력 + shell: bash + + ## slack + action-slack: + needs: CI-CD + runs-on: ubuntu-latest + steps: + - name: Slack Alarm + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: GitHub-Actions CI/CD + fields: repo,message,commit,author,ref,job,took + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required + if: always() # Pick up events even if the job fails or is canceled. diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml deleted file mode 100644 index 6472fa90..00000000 --- a/.github/workflows/gradle.yml +++ /dev/null @@ -1,34 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle - -# github repository Actions 페이지에 나타낼 이름 -name: Dotoriham CI/CD - -# event trigger -on: - push: - branches: - - main - - develop - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v3 - with: - java-version: '11' - distribution: 'temurin' - - name: Build with Gradle - run: ./gradlew build -x test - diff --git a/Dockerfile b/Dockerfile-dev similarity index 51% rename from Dockerfile rename to Dockerfile-dev index 738e7458..043101a5 100644 --- a/Dockerfile +++ b/Dockerfile-dev @@ -1,5 +1,5 @@ FROM openjdk:11-jdk-slim EXPOSE 8080 ARG JAR_FILE=/build/libs/Web-Team-2-Backend-0.0.1-SNAPSHOT.jar -ADD ${JAR_FILE} app.jar -ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=dev","/app.jar"] diff --git a/Dockerfile-prod b/Dockerfile-prod new file mode 100644 index 00000000..f323bd8b --- /dev/null +++ b/Dockerfile-prod @@ -0,0 +1,5 @@ +FROM openjdk:14-jdk-slim +EXPOSE 8080 +ARG JAR_FILE=/build/libs/Web-Team-2-Backend-0.0.1-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","/app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index c3dfcaf7..21d5dd2a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,11 +46,15 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.springframework.boot:spring-boot-starter-batch") + implementation("org.springframework.boot:spring-boot-starter-mail") implementation("io.jsonwebtoken:jjwt:0.9.1") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("io.springfox:springfox-boot-starter:3.0.0") + // https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 + implementation("org.apache.commons:commons-lang3:3.12.0") + // https://mvnrepository.com/artifact/com.slack.api/slack-api-client implementation("com.slack.api:slack-api-client:1.20.2") @@ -147,7 +151,8 @@ fun excludedClassFilesForReport(classDirectories: ConfigurableFileCollection) { "**/exception/**", "**/config/**", "**/Bookmarkers*", - "**/BaseTimeEntity*" + "**/BaseTimeEntity*", + "**/infra/**" ) } }) diff --git a/src/main/kotlin/com/yapp/web2/batch/job/NotificationJob.kt b/src/main/kotlin/com/yapp/web2/batch/job/NotificationJob.kt index 80223005..381358f9 100644 --- a/src/main/kotlin/com/yapp/web2/batch/job/NotificationJob.kt +++ b/src/main/kotlin/com/yapp/web2/batch/job/NotificationJob.kt @@ -27,7 +27,7 @@ class NotificationJob( private val log = LoggerFactory.getLogger(javaClass) - @Bean(name = ["bookmarkNotificationJob"]) + @Bean("bookmarkNotificationJob") fun bookmarkNotificationJob(): Job { return jobBuilderFactory.get("bookmarkNotificationJob") .start(bookmarkNotificationStep()) @@ -62,6 +62,7 @@ class NotificationJob( } } + @Bean fun remindBookmarkWriter(): ItemWriter { return ItemWriter { it.stream().forEach { bookmark -> diff --git a/src/main/kotlin/com/yapp/web2/batch/job/TrashRefreshJob.kt b/src/main/kotlin/com/yapp/web2/batch/job/TrashRefreshJob.kt new file mode 100644 index 00000000..0b2f9952 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/batch/job/TrashRefreshJob.kt @@ -0,0 +1,69 @@ +package com.yapp.web2.batch.job + +import com.yapp.web2.domain.bookmark.entity.Bookmark +import com.yapp.web2.domain.bookmark.repository.BookmarkRepository +import org.springframework.batch.core.Job +import org.springframework.batch.core.Step +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory +import org.springframework.batch.core.launch.support.RunIdIncrementer +import org.springframework.batch.item.ItemProcessor +import org.springframework.batch.item.ItemWriter +import org.springframework.batch.item.support.ListItemReader +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.LocalDateTime + +@Configuration +@EnableBatchProcessing +class TrashRefreshJob( + private val jobBuilderFactory: JobBuilderFactory, + private val stepBuilderFactory: StepBuilderFactory, + private val bookmarkRepository: BookmarkRepository, + private val jobCompletionListener: JobCompletionListener +) { + + @Bean("bookmarkTrashRefreshJob") + fun bookmarkTrashRefreshJob(): Job { + return jobBuilderFactory.get("bookmarkTrashRefreshJob") + .start(trashRefreshStep()) + .incrementer(RunIdIncrementer()) + .listener(jobCompletionListener) + .build() + } + + @Bean + fun trashRefreshStep(): Step { + return stepBuilderFactory.get("trashRefreshStep") + .chunk(10) + .reader(deleteBookmarkReader()) + .processor(deleteBookmarkProcessor()) + .writer(NoOperationItemWriter()) + .build() + } + + @Bean + fun deleteBookmarkReader(): ListItemReader { + val deleteBookmarkList = bookmarkRepository.findAllByDeletedIsTrueAndDeleteTimeIsAfter( + LocalDateTime.now().minusDays(30) + ) + + return ListItemReader(deleteBookmarkList) + } + + @Bean + fun deleteBookmarkProcessor(): ItemProcessor { + return ItemProcessor { + bookmarkRepository.delete(it) + it + } + } +} + +class NoOperationItemWriter : ItemWriter { + override fun write(items: MutableList) { + // no-operation + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/batch/scheduler/TrashRefreshScheduler.kt b/src/main/kotlin/com/yapp/web2/batch/scheduler/TrashRefreshScheduler.kt new file mode 100644 index 00000000..fb786b03 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/batch/scheduler/TrashRefreshScheduler.kt @@ -0,0 +1,26 @@ +package com.yapp.web2.batch.scheduler + +import com.yapp.web2.batch.job.TrashRefreshJob +import org.springframework.batch.core.JobParameter +import org.springframework.batch.core.JobParameters +import org.springframework.batch.core.launch.JobLauncher +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.text.SimpleDateFormat + +@Component +class TrashRefreshScheduler( + private val jobLauncher: JobLauncher, + private val trashRefreshJob: TrashRefreshJob +) { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS") + + @Scheduled(cron = "0 0 2 * * *") + fun trashRefreshSchedule() { + val jobConf = hashMapOf() + jobConf["time"] = JobParameter(dateFormat.format(System.currentTimeMillis())) + val jobParameters = JobParameters(jobConf) + + jobLauncher.run(trashRefreshJob.bookmarkTrashRefreshJob(), jobParameters) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/common/CustomPassword.kt b/src/main/kotlin/com/yapp/web2/common/CustomPassword.kt new file mode 100644 index 00000000..212130e8 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/common/CustomPassword.kt @@ -0,0 +1,18 @@ +package com.yapp.web2.common + +import javax.validation.Constraint +import javax.validation.Payload +import kotlin.annotation.AnnotationTarget.* +import kotlin.reflect.KClass + +@MustBeDocumented +@Constraint(validatedBy = [PasswordValidator::class]) +@Target(ANNOTATION_CLASS, CLASS, CONSTRUCTOR, FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class CustomPassword( + + // TODO: 2022/05/08 필드 별 정확한 의미 파악 + val message: String = "영문 대소문자, 숫자, 특수문자 중 2종류 이상을 조합하여 8~16자의 비밀번호를 생성해주세요.", + val groups: Array> = [], + val payload: Array> = [], +) diff --git a/src/main/kotlin/com/yapp/web2/common/PasswordValidator.kt b/src/main/kotlin/com/yapp/web2/common/PasswordValidator.kt new file mode 100644 index 00000000..73dbe9bf --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/common/PasswordValidator.kt @@ -0,0 +1,22 @@ +package com.yapp.web2.common + +import com.yapp.web2.util.Message +import javax.validation.ConstraintValidator +import javax.validation.ConstraintValidatorContext + +class PasswordValidator : ConstraintValidator { + + override fun isValid(password: String, context: ConstraintValidatorContext?): Boolean { + // 특수문자 + (영문자 or 숫자) 조합으로 8~16자 사이 + val passwordRegex = "^(?=.*[0-9a-zA-Z])(?=.*[!@#\$%^&*])(?=\\S+\$).{8,16}\$" + val regex = Regex(passwordRegex) + val isValidPassword = password.matches(regex) + + if (!isValidPassword) { + context?.disableDefaultConstraintViolation() + context?.buildConstraintViolationWithTemplate(Message.PASSWORD_VALID_MESSAGE) + ?.addConstraintViolation() + } + return isValidPassword + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/config/SecurityConfig.kt b/src/main/kotlin/com/yapp/web2/config/SecurityConfig.kt index f8843eaa..b13fe694 100644 --- a/src/main/kotlin/com/yapp/web2/config/SecurityConfig.kt +++ b/src/main/kotlin/com/yapp/web2/config/SecurityConfig.kt @@ -1,12 +1,17 @@ package com.yapp.web2.config import com.yapp.web2.security.jwt.JwtProvider +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.firewall.DefaultHttpFirewall +import org.springframework.security.web.firewall.HttpFirewall @Configuration @EnableWebSecurity @@ -14,13 +19,23 @@ class SecurityConfig( private val jwtProvider: JwtProvider ) : WebSecurityConfigurerAdapter() { + @Bean + fun getPasswordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + // request rejected 에러 로그 제거 + @Bean + fun defaultHttpFirewall(): HttpFirewall { + return DefaultHttpFirewall() + } + override fun configure(web: WebSecurity?) { - web!!.ignoring() - .antMatchers("/api/v1/user/oauth2Login", "/api/v1/user/reIssuanceAccessToken") - .antMatchers("/v2/**", "/configuration/**", "/swagger*/**", "/webjars/**", "/swagger-resources/**") - .antMatchers("/v3/**", "/swagger-ui", "/swagger-ui/**") - .antMatchers("/actuator/**") - .antMatchers("/api/v1/page/open/**") + web!!.httpFirewall(defaultHttpFirewall()) + .ignoring() + .antMatchers("/api/v1/user/oauth2Login", "/api/v1/user/signUp", "/api/v1/user/signUp/emailCheck") + .antMatchers("/api/v1/user/reIssuanceAccessToken") + .antMatchers("/swagger-resources/**", "/v3/api-docs/**", "/swagger-ui/**") } override fun configure(http: HttpSecurity?) { @@ -30,9 +45,6 @@ class SecurityConfig( .sessionCreationPolicy(SessionCreationPolicy.STATELESS) http.authorizeRequests() - .antMatchers("/api/v1/user/oauth2Login", "/api/v1/user/reIssuanceAccessToken").permitAll() - .antMatchers("/v2/**", "/configuration/**", "/swagger*/**", "/webjars/**", "/swagger-resources/**").permitAll() - .antMatchers("/v3/**", "/swagger-ui", "/swagger-ui/**").permitAll() .antMatchers("/actuator/**").permitAll() .antMatchers("/api/v1/page/open/**").permitAll() .anyRequest().authenticated() diff --git a/src/main/kotlin/com/yapp/web2/config/SwaggerConfig.kt b/src/main/kotlin/com/yapp/web2/config/SwaggerConfig.kt index 8ef0f20f..11631117 100644 --- a/src/main/kotlin/com/yapp/web2/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/yapp/web2/config/SwaggerConfig.kt @@ -1,5 +1,6 @@ package com.yapp.web2.config +import io.swagger.v3.oas.annotations.OpenAPIDefinition import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.web.bind.annotation.RestController @@ -15,10 +16,9 @@ import springfox.documentation.spi.service.contexts.SecurityContext import springfox.documentation.spring.web.plugins.Docket import springfox.documentation.swagger.web.SecurityConfiguration import springfox.documentation.swagger.web.SecurityConfigurationBuilder -import springfox.documentation.swagger2.annotations.EnableSwagger2 @Configuration -@EnableSwagger2 +@OpenAPIDefinition class SwaggerConfig { @Bean @@ -36,7 +36,7 @@ class SwaggerConfig { @Bean fun productApi(): Docket { - return Docket(DocumentationType.SWAGGER_2) + return Docket(DocumentationType.OAS_30) .apiInfo(this.metaInfo()) .securityContexts(listOf(securityContext())) .securitySchemes(listOf(apiKey())) diff --git a/src/main/kotlin/com/yapp/web2/domain/account/controller/AccountController.kt b/src/main/kotlin/com/yapp/web2/domain/account/controller/AccountController.kt index 0935a710..4abb3d22 100644 --- a/src/main/kotlin/com/yapp/web2/domain/account/controller/AccountController.kt +++ b/src/main/kotlin/com/yapp/web2/domain/account/controller/AccountController.kt @@ -28,18 +28,21 @@ class AccountController( private val log = LoggerFactory.getLogger(AccountController::class.java) } + @ApiOperation("프로필 조회 API") @GetMapping("/profileInfo") fun getProfile(request: HttpServletRequest): ResponseEntity { val token = ControllerUtil.extractAccessToken(request) return ResponseEntity.status(HttpStatus.OK).body(accountService.getProfile(token)) } + @ApiOperation("리마인드 조회 API") @GetMapping("/remindInfo") fun getRemind(request: HttpServletRequest): ResponseEntity { val token = ControllerUtil.extractAccessToken(request) return ResponseEntity.status(HttpStatus.OK).body(accountService.getRemindElements(token)) } + @ApiOperation("소셜로그인 API") @PostMapping("/oauth2Login") fun oauth2Login( @RequestBody @ApiParam(value = "회원 정보", required = true) request: Account.AccountProfile @@ -48,7 +51,7 @@ class AccountController( return ResponseEntity.status(HttpStatus.OK).body(loginSuccess) } - @ApiOperation(value = "토큰 재발급") + @ApiOperation(value = "토큰 재발급 API") @GetMapping("/reIssuanceAccessToken") fun reIssuanceAccessToken(request: HttpServletRequest): ResponseEntity { val accessToken = ControllerUtil.extractAccessToken(request) @@ -57,6 +60,7 @@ class AccountController( return ResponseEntity.status(HttpStatus.OK).body(tokenDto) } + @ApiOperation(value = "프로필 이미지 변경 API") @PostMapping("/uploadProfileImage") fun uploadProfileImage(@RequestBody image: MultipartFile): ResponseEntity { var imageUrl: Account.ImageUrl = Account.ImageUrl("") @@ -68,34 +72,53 @@ class AccountController( return ResponseEntity.status(HttpStatus.OK).body(imageUrl) } + @ApiOperation(value = "프로필 편집 API") @PostMapping("/changeProfile") - fun changeProfile(request: HttpServletRequest, @RequestBody @Valid profileChanged: Account.ProfileChanged): ResponseEntity { + fun changeProfile( + request: HttpServletRequest, + @RequestBody @Valid @ApiParam(value = "프로필 이미지, 닉네임 정보") + profileChanged: Account.ProfileChanged + ): ResponseEntity { val token = ControllerUtil.extractAccessToken(request) accountService.changeProfile(token, profileChanged) return ResponseEntity.status(HttpStatus.OK).body(Message.SUCCESS) } + @ApiOperation(value = "닉네임 비교 API") @PostMapping("/nickNameCheck") - fun nickNameCheck(request: HttpServletRequest, @RequestBody @Valid nickName: Account.NextNickName): ResponseEntity { + fun nickNameCheck( + request: HttpServletRequest, + @RequestBody @Valid @ApiParam(value = "비교할 닉네임") nickName: Account.NextNickName + ): ResponseEntity { val token = ControllerUtil.extractAccessToken(request) val result = accountService.checkNickNameDuplication(token, nickName) return ResponseEntity.status(HttpStatus.OK).body(result) } + @ApiOperation(value = "닉네임 변경 API") @PostMapping("/nickNameChange") - fun nickNameChange(request: HttpServletRequest, @RequestBody nickName: Account.NextNickName): ResponseEntity { + fun nickNameChange( + request: HttpServletRequest, + @RequestBody @ApiParam(value = "변경할 닉네임") nickName: Account.NextNickName + ): ResponseEntity { val token = ControllerUtil.extractAccessToken(request) accountService.changeNickName(token, nickName) return ResponseEntity.status(HttpStatus.OK).body(Message.SUCCESS) } + @ApiOperation(value = "배경 색상 변경 API") @PostMapping("/changeBackgroundColor") - fun changeBackgroundColor(request: HttpServletRequest, changeUrl: String): ResponseEntity { + fun changeBackgroundColor( + request: HttpServletRequest, + @RequestBody @ApiParam(value = "변경할 색상 정보") + dto: AccountRequestDto.ChangeBackgroundColorRequest + ): ResponseEntity { val token = ControllerUtil.extractAccessToken(request) - accountService.changeBackgroundColor(token, changeUrl) + accountService.changeBackgroundColor(token, dto) return ResponseEntity.status(HttpStatus.OK).body(Message.SUCCESS) } + @ApiOperation(value = "익스텐션 버전 조회 API") @GetMapping("/{extensionVersion}") fun checkExtensionVersion(@PathVariable extensionVersion: String): ResponseEntity { return ResponseEntity.status(HttpStatus.OK).body(accountService.checkExtension(extensionVersion)) @@ -107,4 +130,96 @@ class AccountController( accountService.acceptInvitation(token, folderToken) return ResponseEntity.status(HttpStatus.OK).body("good") } + + @ApiOperation("회원가입 API") + @PostMapping("/signUp") + fun singUp( + @RequestBody @Valid @ApiParam(value = "회원가입 정보", required = true) + request: AccountRequestDto.SignUpRequest + ): ResponseEntity { + return ResponseEntity.status(HttpStatus.OK).body(accountService.signUp(request)) + } + + @ApiOperation("회원가입 시 이메일 존재여부 확인 API") + @PostMapping("/signUp/emailCheck") + fun checkEmail( + @RequestBody @Valid @ApiParam(value = "회원가입 시 등록 이메일", required = true) + request: AccountRequestDto.SignUpEmailRequest + ): ResponseEntity { + return when (accountService.checkEmail(request)) { + true -> ResponseEntity.status(HttpStatus.CONFLICT).body(Message.EXIST_EMAIL) + false -> ResponseEntity.status(HttpStatus.OK).body(Message.SUCCESS) + } + } + + @ApiOperation("FCM Token 설정 API") + @PostMapping("/fcm-token") + fun registerFcmToken( + request: HttpServletRequest, + @RequestBody @Valid @ApiParam(value = "FCM Token") dto: AccountRequestDto.FcmToken + ): ResponseEntity { + val token = ControllerUtil.extractAccessToken(request) + accountService.registerFcmToken(token, dto) + + return ResponseEntity.status(HttpStatus.OK).body(Message.SUCCESS) + } + + @ApiOperation("일반 로그인 API") + @PostMapping("/signIn") + fun signIn( + @RequestBody @Valid @ApiParam(value = "로그인 정보", required = true) + request: AccountRequestDto.SignInRequest + ): ResponseEntity { + return ResponseEntity.status(HttpStatus.OK).body(accountService.signIn(request)) + } + + @ApiOperation(value = "현재 비밀번호와 입력받은 비밀번호 비교 API") + @PostMapping("/passwordCheck") + fun comparePassword( + request: HttpServletRequest, + @RequestBody @Valid @ApiParam(value = "현재(기존) 비밀번호") dto: AccountRequestDto.CurrentPassword + ): ResponseEntity { + val token = ControllerUtil.extractAccessToken(request) + return ResponseEntity.status(HttpStatus.OK).body(accountService.comparePassword(token, dto)) + } + + @ApiOperation(value = "비밀번호 변경 API") + @PatchMapping("/password") + fun changePassword( + request: HttpServletRequest, + @RequestBody @Valid @ApiParam(value = "기존 비밀번호와 새 비밀번호") dto: AccountRequestDto.PasswordChangeRequest + ): ResponseEntity { + val token = ControllerUtil.extractAccessToken(request) + return ResponseEntity.status(HttpStatus.OK).body(accountService.changePassword(token, dto)) + } + + @ApiOperation(value = "회원 탈퇴 API") + @DeleteMapping("/unregister") + fun deleteAccount(request: HttpServletRequest): ResponseEntity { + val token = ControllerUtil.extractAccessToken(request) + accountService.softDelete(token) + + return ResponseEntity.status(HttpStatus.OK).body(Message.DELETE_ACCOUNT_SUCCEED) + } + + @ApiOperation(value = "비밀번호 재설정 - 이메일이 존재 여부 확인 API") + @PostMapping("/password/emailCheck") + fun checkEmailExist( + request: HttpServletRequest, + @RequestBody @Valid @ApiParam(value = "이메일 주소") dto: AccountRequestDto.EmailCheckRequest + ): ResponseEntity { + val token = ControllerUtil.extractAccessToken(request) + return ResponseEntity.status(HttpStatus.OK).body(accountService.checkEmailExist(token, dto)) + } + + @ApiOperation(value = "비밀번호 재설정 - 임시 비밀번호 생성 및 메일 발송 API") + @PostMapping("/password/reset") + fun sendTempPasswordToEmail(request: HttpServletRequest): ResponseEntity { + val token = ControllerUtil.extractAccessToken(request) + val tempPassword = accountService.createTempPassword() + accountService.updatePassword(token, tempPassword) + + return ResponseEntity.status(HttpStatus.OK).body(accountService.sendMail(token, tempPassword)) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/domain/account/entity/Account.kt b/src/main/kotlin/com/yapp/web2/domain/account/entity/Account.kt index 4dcaa986..125ce0c1 100644 --- a/src/main/kotlin/com/yapp/web2/domain/account/entity/Account.kt +++ b/src/main/kotlin/com/yapp/web2/domain/account/entity/Account.kt @@ -6,7 +6,6 @@ import com.yapp.web2.security.jwt.TokenDto import io.swagger.annotations.ApiModel import io.swagger.annotations.ApiModelProperty import javax.persistence.* -import javax.transaction.Transactional import javax.validation.constraints.NotBlank import javax.validation.constraints.NotEmpty @@ -17,6 +16,10 @@ class Account( ) : BaseTimeEntity() { companion object { + fun signUpToAccount(dto: AccountRequestDto.SignUpRequest, encryptPassword: String, name: String): Account { + return Account(dto.email, encryptPassword, dto.fcmToken, name) + } + fun profileToAccount(dto: AccountProfile): Account { return Account(dto.email, dto.image, dto.name, dto.socialType, dto.fcmToken) } @@ -38,6 +41,16 @@ class Account( const val BASIC_IMAGE_URL: String = "https://yapp-bucket-test.s3.ap-northeast-2.amazonaws.com/basicImage.png" } + constructor(email: String, password: String) : this(email) { + this.password = password + } + + constructor(email: String, encryptPassword: String, fcmToken: String, name: String) : this(email) { + this.password = encryptPassword + this.fcmToken = fcmToken + this.name = name + } + constructor(email: String, image: String, nickname: String, socialType: String, fcmToken: String) : this(email) { this.image = image this.name = nickname @@ -63,12 +76,6 @@ class Account( @Column(nullable = true) var name: String = "" - @Column(nullable = true, length = 10) - var sex: String = "" - - @Column(nullable = true, length = 5) - var age: Int? = null - @Column(nullable = true, length = 20) var socialType: String = "none" @@ -81,14 +88,14 @@ class Account( @Column(nullable = true, length = 255) var image: String = BASIC_IMAGE_URL - @Column(length = 30) + @Column(length = 20) var backgroundColor: String = "black" - @Column(length = 10) + @Column var remindToggle: Boolean = true - @Column(nullable = true, length = 10) - var remindNotiCheck: Boolean = true + @Column + var deleted: Boolean = false @OneToMany(mappedBy = "account") var accountFolderList: MutableList = mutableListOf() @@ -119,6 +126,7 @@ class Account( example = "dOOUnnp-iBs:APA91bF1i7mobIF7kEhi3aVlFuv6A5--P1S..." ) @field: NotEmpty(message = "FCM 토큰을 입력해주세요") + @ApiModelProperty(value = "FCM 토큰", required = true, example = "dOOUnnp-iBs:APA91bF1i7mobIF7kEhi3aVlFuv6A5--P1S...") val fcmToken: String ) @@ -164,4 +172,12 @@ class Account( return false } + + fun softDeleteAccount() { + this.deleted = true + } + + fun updateFcmToken(fcmToken: String) { + this.fcmToken = fcmToken + } } \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/domain/account/entity/AccountRequestDto.kt b/src/main/kotlin/com/yapp/web2/domain/account/entity/AccountRequestDto.kt new file mode 100644 index 00000000..232a8000 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/domain/account/entity/AccountRequestDto.kt @@ -0,0 +1,87 @@ +package com.yapp.web2.domain.account.entity + +import com.yapp.web2.common.CustomPassword +import io.swagger.annotations.ApiModel +import io.swagger.annotations.ApiModelProperty +import javax.validation.constraints.Email +import javax.validation.constraints.NotBlank + +class AccountRequestDto { + + @ApiModel(description = "회원가입 Request DTO") + class SignUpRequest( + + @ApiModelProperty(value = "이메일", required = true, example = "a@gmail.com") + @field: Email(regexp = "^(.+)@(\\S+)$", message = "이메일의 형식을 올바르게 입력해주세요") + @field: NotBlank(message = "이메일을 입력해주세요") + val email: String, + + @ApiModelProperty(value = "비밀번호", required = true, example = "!1234567") + @field: NotBlank(message = "비밀번호를 입력해주세요") + @field: CustomPassword + val password: String, + + @ApiModelProperty(value = "FCM 토큰", required = true, example = "dOOUnnp-iBs:APA91bF1i7mobIF7kEhi3aVlFuv6A5--P1S...") + val fcmToken: String + ) + + @ApiModel(description = "회원가입 할 때 이메일 Request DTO") + class SignUpEmailRequest( + @ApiModelProperty(value = "이메일", required = true, example = "a@gmail.com") + @field: Email(regexp = "^(.+)@(\\S+)$", message = "이메일 주소가 올바르지 않습니다") + @field: NotBlank(message = "이메일을 입력해주세요") + val email: String + ) + + @ApiModel(description = "로그인 Request DTO") + class SignInRequest( + @ApiModelProperty(value = "이메일", required = true, example = "a@gmail.com") + @field: Email(regexp = "^(.+)@(\\S+)$", message = "이메일의 형식을 올바르게 입력해주세요") + @field: NotBlank(message = "이메일을 입력해주세요") + val email: String, + + @ApiModelProperty(value = "비밀번호", required = true, example = "!1234567") + @field: CustomPassword + val password: String + ) + + @ApiModel(description = "현재 비밀번호 조회 Request DTO") + class CurrentPassword( + @ApiModelProperty(value = "현재 비밀번호", required = true, example = "1234567!") + @field: NotBlank(message = "현재 비밀번호를 입력해주세요") + val currentPassword: String + ) + + @ApiModel(description = "비밀번호 변경 Request DTO") + class PasswordChangeRequest( + @ApiModelProperty(value = "기존 비밀번호", required = true, example = "1234567!") + @field: NotBlank(message = "기존 비밀번호를 입력해주세요") + @field: CustomPassword + val currentPassword: String, + + @ApiModelProperty(value = "새 비밀번호", required = true, example = "12345678!") + @field: NotBlank(message = "새 비밀번호를 입력해주세요") + @field: CustomPassword + val newPassword: String + ) + + @ApiModel(description = "비밀번호 재설정 - 이메일 주소 확인 Request DTO") + class EmailCheckRequest( + @ApiModelProperty(value = "이메일 주소", required = true, example = "a@a.com") + @field: NotBlank(message = "이메일을 입력해주세요") + val email: String + ) + + @ApiModel(description = "배경 색상 변경 Request DTO") + class ChangeBackgroundColorRequest( + @ApiModelProperty(value = "변경할 색상 이미지 url", required = true, example = "https://aaa.com") + val changeUrl: String + ) + + @ApiModel(description = "FCM Token Request DTO") + class FcmToken( + @ApiModelProperty(value = "등록할 FCM Token 값", required = true, example = "fvczxj3AcxzcndmVf-sdfd..") + val fcmToken: String + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/domain/account/entity/AccountResponseDto.kt b/src/main/kotlin/com/yapp/web2/domain/account/entity/AccountResponseDto.kt new file mode 100644 index 00000000..98c2fdee --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/domain/account/entity/AccountResponseDto.kt @@ -0,0 +1,6 @@ +package com.yapp.web2.domain.account.entity + +class AccountResponseDto { + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/domain/account/service/AccountService.kt b/src/main/kotlin/com/yapp/web2/domain/account/service/AccountService.kt index 63f17dd2..9ff4c74f 100644 --- a/src/main/kotlin/com/yapp/web2/domain/account/service/AccountService.kt +++ b/src/main/kotlin/com/yapp/web2/domain/account/service/AccountService.kt @@ -7,31 +7,46 @@ import com.yapp.web2.security.jwt.TokenDto import com.yapp.web2.config.S3Uploader import com.yapp.web2.domain.folder.entity.AccountFolder import com.yapp.web2.domain.folder.entity.Authority +import com.yapp.web2.domain.account.entity.AccountRequestDto import com.yapp.web2.domain.folder.service.FolderService import com.yapp.web2.exception.BusinessException import com.yapp.web2.exception.custom.AlreadyInvitedException +import com.yapp.web2.exception.custom.EmailNotFoundException import com.yapp.web2.exception.custom.ExistNameException import com.yapp.web2.exception.custom.FolderNotRootException import com.yapp.web2.exception.custom.ImageNotFoundException import com.yapp.web2.util.AES256Util +import com.yapp.web2.exception.custom.PasswordMismatchException import com.yapp.web2.util.Message +import org.apache.commons.lang3.RandomStringUtils import org.springframework.beans.factory.annotation.Value +import org.springframework.mail.SimpleMailMessage +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile +import java.lang.IllegalStateException import javax.transaction.Transactional @Service +@Transactional class AccountService( private val folderService: FolderService, private val accountRepository: AccountRepository, private val jwtProvider: JwtProvider, private val s3Uploader: S3Uploader, private val aes256Util: AES256Util + private val s3Uploader: S3Uploader, + private val passwordEncoder: PasswordEncoder, + private val mailSender: JavaMailSender ) { @Value("\${extension.version}") private lateinit var extensionVersion: String + @Value("\${spring.mail.username}") + private lateinit var fromSender: String + companion object { private const val DIR_NAME = "static" } @@ -47,27 +62,65 @@ class AccountService( } fun oauth2LoginUser(dto: Account.AccountProfile): Account.AccountLoginSuccess { - var account = Account.profileToAccount(dto) + val account = Account.profileToAccount(dto) + val existAccount = accountRepository.findByEmail(account.email) + return signUpOrLogin(account, existAccount) + } + + private fun signUpOrLogin( + account: Account, + existAccount: Account? + ): Account.AccountLoginSuccess { var isRegistered = true + var account2 = account - account = when (val savedAccount = accountRepository.findByEmail(account.email)) { + account2 = when (existAccount) { null -> { isRegistered = false - val account2 = createUser(account) + val newAccount = createUser(account) folderService.createDefaultFolder(account) - account2 + newAccount } else -> { - savedAccount.fcmToken = account.fcmToken - createUser(savedAccount) + existAccount.fcmToken = account2.fcmToken + createUser(existAccount) } } - val tokenDto = jwtProvider.createToken(account) + return Account.AccountLoginSuccess(jwtProvider.createToken(account2), account2, isRegistered) + } + + fun signUp(dto: AccountRequestDto.SignUpRequest): Account.AccountLoginSuccess { + if (accountRepository.findByEmail(dto.email) != null) { + throw IllegalStateException(Message.EXIST_USER) + } - return Account.AccountLoginSuccess(tokenDto, account, isRegistered) + val encryptPassword = passwordEncoder.encode(dto.password) + val nickName = getNickName(dto.email) + val newAccount = createUser(Account.signUpToAccount(dto, encryptPassword, nickName)) + folderService.createDefaultFolder(newAccount) + + return Account.AccountLoginSuccess(jwtProvider.createToken(newAccount), newAccount, false) + } + + fun checkEmail(dto: AccountRequestDto.SignUpEmailRequest): Boolean { + if (isExistEmail(dto.email)) { + return true + } + return false + } + + private fun isExistEmail(email: String) = accountRepository.findByEmail(email) != null + + fun registerFcmToken(token: String, dto: AccountRequestDto.FcmToken) { + val account = jwtProvider.getAccountFromToken(token) + account.updateFcmToken(dto.fcmToken) + } + + internal fun getNickName(email: String): String { + val atIndex = email.indexOf("@") + return email.substring(0, atIndex) } - @Transactional fun createUser(account: Account): Account { return accountRepository.save(account) } @@ -124,10 +177,9 @@ class AccountService( } } - @Transactional - fun changeBackgroundColor(token: String, changeUrl: String) { + fun changeBackgroundColor(token: String, dto: AccountRequestDto.ChangeBackgroundColorRequest) { val account = jwtProvider.getAccountFromToken(token) - account.backgroundColor = changeUrl + account.backgroundColor = dto.changeUrl } fun checkExtension(userVersion: String): String { @@ -151,4 +203,67 @@ class AccountService( // account.addAccountFolder(accountFolder) rootFolder.folders?.add(accountFolder) } + + fun signIn(request: AccountRequestDto.SignInRequest): Account.AccountLoginSuccess? { + val account = accountRepository.findByEmail(request.email) ?: throw IllegalStateException(Message.NOT_EXIST_EMAIL) + + if (!passwordEncoder.matches(request.password, account.password)) { + throw IllegalStateException(Message.USER_PASSWORD_MISMATCH) + } + + return Account.AccountLoginSuccess(jwtProvider.createToken(account), account, false) + } + + fun comparePassword(token: String, dto: AccountRequestDto.CurrentPassword): String { + val account = jwtProvider.getAccountFromToken(token) + if (!passwordEncoder.matches(dto.currentPassword, account.password)) { + throw PasswordMismatchException() + } + return Message.SAME_PASSWORD + } + + fun changePassword(token: String, dto: AccountRequestDto.PasswordChangeRequest): String { + val account = jwtProvider.getAccountFromToken(token) + if (!passwordEncoder.matches(dto.currentPassword, account.password)) { + throw PasswordMismatchException() + } + account.password = passwordEncoder.encode(dto.newPassword) + return Message.CHANGE_PASSWORD_SUCCEED + } + + fun softDelete(token: String) { + val account = jwtProvider.getAccountFromToken(token) + account.softDeleteAccount() + } + + fun checkEmailExist(token: String, request: AccountRequestDto.EmailCheckRequest): String { + accountRepository.findByEmail(request.email)?.let { + if (it.email != request.email) { + throw EmailNotFoundException() + } + } + return Message.SUCCESS_EXIST_EMAIL + } + + internal fun createTempPassword(): String { + return RandomStringUtils.randomAlphanumeric(12) + "!" + } + + fun sendMail(token: String, tempPassword: String): String { + val account = jwtProvider.getAccountFromToken(token) + val mailMessage = SimpleMailMessage() + mailMessage.setTo(account.email) + mailMessage.setFrom(fromSender) + mailMessage.setSubject("${account.name} 님의 임시비밀번호 안내 메일입니다.") + mailMessage.setText("안녕하세요. \n\n 임시 비밀번호를 전달드립니다. \n\n 임시 비밀번호는: $tempPassword 입니다.") + mailSender.send(mailMessage) + + return Message.SUCCESS_SEND_MAIL + } + + fun updatePassword(token: String, tempPassword: String) { + val account = jwtProvider.getAccountFromToken(token) + account.password = passwordEncoder.encode(tempPassword) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/domain/bookmark/repository/BookmarkRepository.kt b/src/main/kotlin/com/yapp/web2/domain/bookmark/repository/BookmarkRepository.kt index 116448d0..2fc8fecc 100644 --- a/src/main/kotlin/com/yapp/web2/domain/bookmark/repository/BookmarkRepository.kt +++ b/src/main/kotlin/com/yapp/web2/domain/bookmark/repository/BookmarkRepository.kt @@ -1,5 +1,3 @@ - - package com.yapp.web2.domain.bookmark.repository import com.yapp.web2.domain.bookmark.entity.Bookmark @@ -8,6 +6,7 @@ import org.springframework.data.domain.Pageable import org.springframework.data.mongodb.repository.MongoRepository import org.springframework.data.mongodb.repository.Query import org.springframework.stereotype.Repository +import java.time.LocalDateTime @Repository interface BookmarkRepository : MongoRepository { @@ -39,4 +38,6 @@ interface BookmarkRepository : MongoRepository { fun findAllByUserId(userId: Long): List fun findAllByUserIdAndRemindCheckIsFalseAndRemindStatusIsTrueAndRemindTimeIsNotNull(userId: Long): List + + fun findAllByDeletedIsTrueAndDeleteTimeIsAfter(time: LocalDateTime): List } \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkInterfaceService.kt b/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkInterfaceService.kt new file mode 100644 index 00000000..a93cfa81 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkInterfaceService.kt @@ -0,0 +1,61 @@ +package com.yapp.web2.domain.bookmark.service + +import com.yapp.web2.domain.bookmark.entity.* +import com.yapp.web2.domain.bookmark.repository.BookmarkInterfaceRepository +import com.yapp.web2.domain.folder.entity.Folder +import com.yapp.web2.domain.folder.repository.FolderRepository +import com.yapp.web2.exception.custom.ObjectNotFoundException +import com.yapp.web2.exception.custom.BookmarkNotFoundException +import com.yapp.web2.security.jwt.JwtProvider +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class BookmarkInterfaceService( + private val bookmarkInterfaceRepository: BookmarkInterfaceRepository, + private val folderRepository: FolderRepository, + private val jwtProvider: JwtProvider +) { + fun moveBookmarkList(moveBookmarkDto: BookmarkDto.MoveBookmarkDto) { + val bookmarkIdList = moveBookmarkDto.bookmarkIdList + for (bookmarkId in bookmarkIdList) + moveBookmark(bookmarkId, moveBookmarkDto) + } + + fun moveBookmark(bookmarkId: String, moveBookmarkDto: BookmarkDto.MoveBookmarkDto) { + val bookmark = getBookmarkIfPresent(bookmarkId) + val nextFolder = checkFolderAbsence(moveBookmarkDto.nextFolderId) + + bookmark.folderId?.let { + if(isSameFolder(it, moveBookmarkDto.nextFolderId)) return + val beforeFolder = checkFolderAbsence(it) + updateBookmarkCountByFolderId(beforeFolder, -1) + } + + bookmark.changeFolderInfo(nextFolder) + updateBookmarkCountByFolderId(nextFolder, 1) + + bookmarkInterfaceRepository.save(bookmark) + } + + @Transactional + protected fun updateBookmarkCountByFolderId(folder: Folder, count: Int) { + folder.bookmarkCount += count + } + + private fun isSameFolder(prevFolderId: Long, nextFolderId: Long) = prevFolderId == nextFolderId + + private fun getBookmarkIfPresent(bookmarkId: String): BookmarkInterface { + return bookmarkInterfaceRepository.findBookmarkInterfaceById(bookmarkId) + ?: throw BookmarkNotFoundException() + } + private fun checkFolderAbsence(folderId: Long): Folder { + return when (val folder = folderRepository.findFolderById(folderId)) { + null -> throw ObjectNotFoundException() + else -> folder + } + } + + fun remindToggleOn(token: String, bookmarkId: String) { + val account = jwtProvider.getAccountFromToken(token) + val bookmark = getBookmarkIfPresent(bookmarkId) diff --git a/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkService.kt b/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkService.kt index 681c7584..5f50fff8 100644 --- a/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkService.kt +++ b/src/main/kotlin/com/yapp/web2/domain/bookmark/service/BookmarkService.kt @@ -6,7 +6,7 @@ import com.yapp.web2.domain.bookmark.entity.Remind import com.yapp.web2.domain.bookmark.repository.BookmarkRepository import com.yapp.web2.domain.folder.entity.Folder import com.yapp.web2.domain.folder.repository.FolderRepository -import com.yapp.web2.exception.ObjectNotFoundException +import com.yapp.web2.exception.custom.ObjectNotFoundException import com.yapp.web2.exception.custom.BookmarkNotFoundException import com.yapp.web2.exception.custom.SameBookmarkException import com.yapp.web2.security.jwt.JwtProvider diff --git a/src/main/kotlin/com/yapp/web2/domain/folder/controller/FolderController.kt b/src/main/kotlin/com/yapp/web2/domain/folder/controller/FolderController.kt index 21a4a0cf..9597ec2f 100644 --- a/src/main/kotlin/com/yapp/web2/domain/folder/controller/FolderController.kt +++ b/src/main/kotlin/com/yapp/web2/domain/folder/controller/FolderController.kt @@ -20,7 +20,7 @@ import javax.validation.Valid class FolderController( private val folderService: FolderService ) { - @ApiOperation(value = "폴더 생성") + @ApiOperation(value = "폴더 생성 API") @PostMapping fun createFolder( servletRequest: HttpServletRequest, @@ -31,7 +31,7 @@ class FolderController( return ResponseEntity.status(HttpStatus.OK).body(folderId?.let { Folder.FolderCreateResponse(it) }) } - @ApiOperation("폴더 수정") + @ApiOperation("폴더 수정 API") @PatchMapping("/{folderId}") fun updateFolder( @PathVariable @ApiParam(value = "폴더 ID", example = "4", required = true) folderId: Long, @@ -112,6 +112,7 @@ class FolderController( } // TODO: 2022/06/22 유저 정보 확인 + @ApiOperation(value = "암호화된 폴더 ID 조회 API") @GetMapping("encrypt/{folderId}") fun getEncryptFolderId(@PathVariable folderId: Long): ResponseEntity { return ResponseEntity.status(HttpStatus.OK).body(folderService.encryptFolderId(folderId)) diff --git a/src/main/kotlin/com/yapp/web2/domain/folder/service/FolderService.kt b/src/main/kotlin/com/yapp/web2/domain/folder/service/FolderService.kt index 0bcf1b09..ae341b78 100644 --- a/src/main/kotlin/com/yapp/web2/domain/folder/service/FolderService.kt +++ b/src/main/kotlin/com/yapp/web2/domain/folder/service/FolderService.kt @@ -192,8 +192,7 @@ class FolderService( @Transactional(readOnly = true) fun findByFolderId(folderId: Long): Folder { - return folderRepository.findById(folderId) - .orElseThrow { folderNotFoundException } + return folderRepository.findById(folderId).orElseThrow { folderNotFoundException } } fun deleteFolder(folder: Folder) { diff --git a/src/main/kotlin/com/yapp/web2/exception/ObjectNotFoundException.kt b/src/main/kotlin/com/yapp/web2/exception/ObjectNotFoundException.kt deleted file mode 100644 index 042e0726..00000000 --- a/src/main/kotlin/com/yapp/web2/exception/ObjectNotFoundException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.yapp.web2.exception - -import com.yapp.web2.util.Message - -open class ObjectNotFoundException() : BusinessException(Message.OBJECT_NOT_FOUND) \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/exception/custom/EmailNotFoundException.kt b/src/main/kotlin/com/yapp/web2/exception/custom/EmailNotFoundException.kt new file mode 100644 index 00000000..68de4dac --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/exception/custom/EmailNotFoundException.kt @@ -0,0 +1,6 @@ +package com.yapp.web2.exception.custom + +import com.yapp.web2.exception.BusinessException +import com.yapp.web2.util.ExceptionMessage + +class EmailNotFoundException : BusinessException(ExceptionMessage.NOT_FOUND_EMAIL) \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/exception/custom/ObjectNotFoundException.kt b/src/main/kotlin/com/yapp/web2/exception/custom/ObjectNotFoundException.kt new file mode 100644 index 00000000..3d08a523 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/exception/custom/ObjectNotFoundException.kt @@ -0,0 +1,6 @@ +package com.yapp.web2.exception.custom + +import com.yapp.web2.exception.BusinessException +import com.yapp.web2.util.Message + +open class ObjectNotFoundException : BusinessException(Message.OBJECT_NOT_FOUND) \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/exception/custom/PasswordMismatchException.kt b/src/main/kotlin/com/yapp/web2/exception/custom/PasswordMismatchException.kt new file mode 100644 index 00000000..ed5e3b90 --- /dev/null +++ b/src/main/kotlin/com/yapp/web2/exception/custom/PasswordMismatchException.kt @@ -0,0 +1,6 @@ +package com.yapp.web2.exception.custom + +import com.yapp.web2.exception.BusinessException +import com.yapp.web2.util.ExceptionMessage + +class PasswordMismatchException : BusinessException(ExceptionMessage.PASSWORD_DIFFERENT_EXCEPTION) \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/util/ExceptionMessage.kt b/src/main/kotlin/com/yapp/web2/util/ExceptionMessage.kt index 1eb7e43a..db7f884d 100644 --- a/src/main/kotlin/com/yapp/web2/util/ExceptionMessage.kt +++ b/src/main/kotlin/com/yapp/web2/util/ExceptionMessage.kt @@ -20,5 +20,9 @@ class ExceptionMessage { const val NO_PERMISSION = "권한이 없습니다." const val NOT_SAME_ROOT_FOLDER = "동일한 보관함이 아닙니다." + + const val PASSWORD_DIFFERENT_EXCEPTION = "비밀번호가 일치하지 않습니다." + + const val NOT_FOUND_EMAIL = "가입하신 이메일 주소를 찾을 수 없습니다." } } \ No newline at end of file diff --git a/src/main/kotlin/com/yapp/web2/util/Message.kt b/src/main/kotlin/com/yapp/web2/util/Message.kt index 45adc33d..30c824d7 100644 --- a/src/main/kotlin/com/yapp/web2/util/Message.kt +++ b/src/main/kotlin/com/yapp/web2/util/Message.kt @@ -14,10 +14,12 @@ class Message { var WRONG_TOKEN_FORM = "형식에 어긋난 토큰입니다" - var NULL_TOKEN = "값이 존재하지 않습니다" + var NULL_TOKEN = "토큰이 존재하지 않습니다" var EXIST_NAME = "이미 존재하는 닉네임입니다" + var EXIST_USER = "이미 존재하는 회원입니다." + var AVAILABLE_NAME = "사용가능한 닉네임입니다" var CLICK = "카운트가 증가되었습니다" @@ -39,6 +41,25 @@ class Message { var NOTIFICATION_MESSAGE = "깜빡한 도토리가 있지 않나요?" var OLD_EXTENSION_VERSION = "익스텐션 버전이 오래되었습니다!" + var LATEST_EXTENSION_VERSION = "최신 버전입니다." + + var USER_PASSWORD_MISMATCH = "비밀번호가 일치하지 않습니다." + + var SAME_PASSWORD = "현재 비밀번호와 동일합니다." + + var CHANGE_PASSWORD_SUCCEED = "비밀번호가 정상적으로 변경되었습니다." + + var DELETE_ACCOUNT_SUCCEED = "정상적으로 탈퇴되었습니다." + + var PASSWORD_VALID_MESSAGE = "특수문자를 포함하여 영문 대소문자, 숫자 중 2종류 이상을 조합하여 8~16자의 비밀번호를 생성해주세요." + + var NOT_EXIST_EMAIL = "존재하지 않는 이메일 입니다." + + var EXIST_EMAIL = "이미 가입한 이메일 주소입니다." + + var SUCCESS_SEND_MAIL = "회원의 이메일에 임시 비밀번호가 정상적으로 발송되었습니다." + + var SUCCESS_EXIST_EMAIL = "입력하신 이메일 주소가 정상적으로 확인되었습니다." } } \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 46c80075..551d5728 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,8 +1,13 @@ + - - + + + + + + @@ -11,12 +16,34 @@ - ./log/debug.log + + ${LOG_FILE_PROD} + + + + ${LOG_FILE_DEV} + + + + ${LOG_FILE_LOCAL} + + - ./log/%d{yyyy-MM}/log.%d{yyyy-MM-dd}.%i.log + + ${LOG_DIR_SERVER}/%d{yyyy-MM}/log.%d{yyyy-MM-dd}.%i.log + + + + ${LOG_DIR_SERVER}/%d{yyyy-MM}/log.%d{yyyy-MM-dd}.%i.log + + + + ./log/%d{yyyy-MM}/log.%d{yyyy-MM-dd}.%i.log + + 10MB - 90 + 180 [%d{yyyy-MM-dd HH:mm:ss}][%thread] %-5level %logger{36} - %msg%n diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index bf1ddd30..93cdad93 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,37 +1,119 @@ - --- account -CREATE TABLE IF NOT EXISTS account +-- Table: public.account +CREATE TABLE IF NOT EXISTS public.account ( id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), created_at timestamp without time zone, updated_at timestamp without time zone, - age integer, background_color character varying(30) COLLATE pg_catalog."default", - email character varying(255) COLLATE pg_catalog."default", + email character varying(60) COLLATE pg_catalog."default", fcm_token character varying(255) COLLATE pg_catalog."default", image character varying(255) COLLATE pg_catalog."default", - name character varying(255) COLLATE pg_catalog."default", - password character varying(255) COLLATE pg_catalog."default", + name character varying(40) COLLATE pg_catalog."default", + password character varying(200) COLLATE pg_catalog."default", remind_cycle integer, - remind_noti_check boolean, remind_toggle boolean, - sex character varying(10) COLLATE pg_catalog."default", social_type character varying(20) COLLATE pg_catalog."default", + deleted boolean, CONSTRAINT account_pkey PRIMARY KEY (id), CONSTRAINT uk_q0uja26qgu1atulenwup9rxyr UNIQUE (email) - ); +); + +ALTER TABLE public.account + OWNER to yapp; + +COMMENT ON COLUMN public.account.id + IS '회원 ID'; + +COMMENT ON COLUMN public.account.created_at + IS '생성 일시'; + +COMMENT ON COLUMN public.account.updated_at + IS '수정 일시'; + +COMMENT ON COLUMN public.account.background_color + IS '배경 색상'; + +COMMENT ON COLUMN public.account.email + IS '이메일'; + +COMMENT ON COLUMN public.account.fcm_token + IS 'FCM 토큰'; + +COMMENT ON COLUMN public.account.image + IS '프로필 이미지'; + +COMMENT ON COLUMN public.account.name + IS '닉네임'; + +COMMENT ON COLUMN public.account.password + IS '비밀번호'; + +COMMENT ON COLUMN public.account.remind_cycle + IS '리마인드 주기(3일, 7일, 14일, 30일)'; +COMMENT ON COLUMN public.account.remind_toggle + IS '리마인드 토글 여부'; -ALTER TABLE account +COMMENT ON COLUMN public.account.social_type + IS '소셜로그인 유형'; + +COMMENT ON COLUMN public.account.deleted + IS '회원 탈퇴 여부'; + + +-- Table: public.folder +CREATE TABLE IF NOT EXISTS public.folder +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), + created_at timestamp without time zone, + updated_at timestamp without time zone, + bookmark_count integer NOT NULL, + emoji character varying(100) COLLATE pg_catalog."default", + index integer NOT NULL, + name character varying(40) COLLATE pg_catalog."default", + parent_id bigint, + CONSTRAINT folder_pkey PRIMARY KEY (id), + CONSTRAINT fkn0cjh1seljcp0mc4tj1ufh99m FOREIGN KEY (parent_id) + REFERENCES public.folder (id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE CASCADE +); + +ALTER TABLE public.folder OWNER to yapp; +COMMENT ON COLUMN public.folder.id + IS '폴더 ID'; + +COMMENT ON COLUMN public.folder.created_at + IS '생성 일시'; + +COMMENT ON COLUMN public.folder.updated_at + IS '수정 일시'; + +COMMENT ON COLUMN public.folder.bookmark_count + IS '북마크 갯수'; + +COMMENT ON COLUMN public.folder.emoji + IS '이모지'; + +COMMENT ON COLUMN public.folder.index + IS '폴더 순서(계층형 구조)'; + +COMMENT ON COLUMN public.folder.name + IS '폴더 이름'; + +COMMENT ON COLUMN public.folder.parent_id + IS '상위 폴더 ID'; + --- account_folder -CREATE TABLE IF NOT EXISTS account_folder +-- Table: public.account_folder +CREATE TABLE IF NOT EXISTS public.account_folder ( id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), account_id bigint, folder_id bigint, + authority character varying(20) COLLATE pg_catalog."default", CONSTRAINT account_folder_pkey PRIMARY KEY (id), CONSTRAINT fkpu24hyoea9vik0oexl3j8r2tu FOREIGN KEY (account_id) REFERENCES public.account (id) MATCH SIMPLE @@ -41,29 +123,19 @@ CREATE TABLE IF NOT EXISTS account_folder REFERENCES public.folder (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE - ); +); -ALTER TABLE account_folder +ALTER TABLE public.account_folder OWNER to yapp; +COMMENT ON COLUMN public.account_folder.id + IS 'account_folder ID'; --- folder -CREATE TABLE IF NOT EXISTS folder -( - id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), - created_at timestamp without time zone, - updated_at timestamp without time zone, - bookmark_count integer NOT NULL, - emoji character varying(255) COLLATE pg_catalog."default", - index integer NOT NULL, - name character varying(255) COLLATE pg_catalog."default", - parent_id bigint, - CONSTRAINT folder_pkey PRIMARY KEY (id), - CONSTRAINT fkn0cjh1seljcp0mc4tj1ufh99m FOREIGN KEY (parent_id) - REFERENCES public.folder (id) MATCH SIMPLE - ON UPDATE NO ACTION - ON DELETE CASCADE - ); +COMMENT ON COLUMN public.account_folder.account_id + IS '회원 ID'; -ALTER TABLE folder - OWNER to yapp; +COMMENT ON COLUMN public.account_folder.folder_id + IS '폴더 ID'; + +COMMENT ON COLUMN public.account_folder.authority + IS '공유 보관함 관련 권한'; diff --git a/src/test/kotlin/com/yapp/web2/BookmarkersApplicationTest.kt b/src/test/kotlin/com/yapp/web2/BookmarkersApplicationTest.kt deleted file mode 100644 index 539a1601..00000000 --- a/src/test/kotlin/com/yapp/web2/BookmarkersApplicationTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.yapp.web2 - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles - -@ActiveProfiles("dev") -@SpringBootTest -internal class BookmarkersApplicationTest { - - @Value("\${cloud.aws.region.static}") - lateinit var actualRegion: String - - @Test - fun `application-dev properties 값을 확인한다`() { - // given - val expected = "ap-northeast-2" - - // then - assertThat(actualRegion).isEqualTo(expected) - } -} \ No newline at end of file diff --git a/src/test/kotlin/com/yapp/web2/batch/BatchTestConfig.kt b/src/test/kotlin/com/yapp/web2/batch/BatchTestConfig.kt new file mode 100644 index 00000000..2b2376cd --- /dev/null +++ b/src/test/kotlin/com/yapp/web2/batch/BatchTestConfig.kt @@ -0,0 +1,12 @@ +package com.yapp.web2.batch + +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableAutoConfiguration +@EnableBatchProcessing +class BatchTestConfig { + +} \ No newline at end of file diff --git a/src/test/kotlin/com/yapp/web2/batch/job/TrashRefreshJobTest.kt b/src/test/kotlin/com/yapp/web2/batch/job/TrashRefreshJobTest.kt new file mode 100644 index 00000000..f1918338 --- /dev/null +++ b/src/test/kotlin/com/yapp/web2/batch/job/TrashRefreshJobTest.kt @@ -0,0 +1,50 @@ +package com.yapp.web2.batch.job + +import com.yapp.web2.batch.BatchTestConfig +import com.yapp.web2.domain.bookmark.repository.BookmarkRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.jupiter.api.assertAll +import org.junit.runner.RunWith +import org.springframework.batch.core.ExitStatus +import org.springframework.batch.test.JobLauncherTestUtils +import org.springframework.batch.test.context.SpringBatchTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.junit4.SpringRunner + +@SpringBatchTest +@SpringBootTest(classes = [ + TrashRefreshJob::class, BatchTestConfig::class, JobCompletionListener::class +]) +@RunWith(SpringRunner::class) +@ActiveProfiles("dev") +@EnableMongoRepositories(basePackages = ["com.yapp.web2.domain.bookmark.repository"]) +internal class TrashRefreshJobTest { + + @Autowired + lateinit var jobLauncherTestUtils: JobLauncherTestUtils + + @Autowired + lateinit var bookmarkRepository: BookmarkRepository + + @Test + fun `bookmarkTrashRefreshJob을 정상적으로 수행한다`() { + // given + val expectedJobName = "bookmarkTrashRefreshJob" + val expectedStepSize = 1 + + // when + val jobExecution = jobLauncherTestUtils.launchJob() + + // then + assertAll( + { assertThat(jobExecution.jobInstance.jobName).isEqualTo(expectedJobName) }, + { assertThat(jobExecution.exitStatus).isEqualTo(ExitStatus.COMPLETED) }, + { assertThat(jobExecution.stepExecutions.size).isEqualTo(expectedStepSize) } + ) + } + +} diff --git a/src/test/kotlin/com/yapp/web2/domain/ControllerTestUtil.kt b/src/test/kotlin/com/yapp/web2/domain/ControllerTestUtil.kt new file mode 100644 index 00000000..c618ef52 --- /dev/null +++ b/src/test/kotlin/com/yapp/web2/domain/ControllerTestUtil.kt @@ -0,0 +1,52 @@ +package com.yapp.web2.domain + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import java.nio.charset.StandardCharsets + +class ControllerTestUtil { + + fun getResultAction(uri: String, mockMvc: MockMvc): ResultActions { + return mockMvc.perform( + MockMvcRequestBuilders.get(uri) + .header("AccessToken", "token") + .header("RefreshToken", "retoken") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8.toString()) + ) + } + + fun postResultAction(uri: String, request: Any, mockMvc: MockMvc): ResultActions { + return mockMvc.perform( + MockMvcRequestBuilders.post(uri) + .content(jacksonObjectMapper().writeValueAsString(request)) + .header("AccessToken", "token") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ) + } + + fun patchResultAction(uri: String, request: Any, mockMvc: MockMvc): ResultActions { + return mockMvc.perform( + MockMvcRequestBuilders.patch(uri) + .content(jacksonObjectMapper().writeValueAsString(request)) + .header("AccessToken", "token") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ) + } + + fun deleteResultAction(uri: String, mockMvc: MockMvc): ResultActions { + return mockMvc.perform( + MockMvcRequestBuilders.delete(uri) + .header("AccessToken", "token") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/yapp/web2/domain/account/controller/AccountControllerTest.kt b/src/test/kotlin/com/yapp/web2/domain/account/controller/AccountControllerTest.kt new file mode 100644 index 00000000..62265097 --- /dev/null +++ b/src/test/kotlin/com/yapp/web2/domain/account/controller/AccountControllerTest.kt @@ -0,0 +1,350 @@ +package com.yapp.web2.domain.account.controller + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.ninjasquad.springmockk.MockkBean +import com.yapp.web2.config.S3Uploader +import com.yapp.web2.domain.ControllerTestUtil +import com.yapp.web2.domain.account.entity.Account +import com.yapp.web2.domain.account.entity.AccountRequestDto +import com.yapp.web2.domain.account.service.AccountService +import com.yapp.web2.security.jwt.JwtProvider +import com.yapp.web2.security.jwt.TokenDto +import com.yapp.web2.util.Message +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import org.apache.commons.lang3.RandomStringUtils +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.result.MockMvcResultHandlers +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext +import org.springframework.web.filter.CharacterEncodingFilter +import java.nio.charset.StandardCharsets + +@WebMvcTest(AccountController::class, excludeAutoConfiguration = [SecurityAutoConfiguration::class]) +@AutoConfigureMockMvc(addFilters = false) +@JsonIgnoreProperties(value = ["hibernateLazyInitializer", "handler"]) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestPropertySource(properties = ["extension.version=4.0.3"]) +class AccountControllerTest { + + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var context: WebApplicationContext + + @MockkBean + private lateinit var accountService: AccountService + + @MockkBean + private lateinit var s3Uploader: S3Uploader + + @MockkBean + private lateinit var jwtProvider: JwtProvider + + private val util = ControllerTestUtil() + + @Value("\${extension.version}") + private lateinit var extensionVersion: String + + @BeforeAll + fun setup() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .addFilters(CharacterEncodingFilter(StandardCharsets.UTF_8.toString(), true)) + .alwaysDo(MockMvcResultHandlers.print()) + .build() + } + + @Test + fun `현재 회원의 프로필을 조회한다`() { + // given + val accountProfile = Account.AccountProfile( + "a@a.com", "Nickname", "https://s3-test.com", "google", "FCMToken" + ) + every { accountService.getProfile(any()) } returns accountProfile + + // when + val resultAction = util.getResultAction("/api/v1/user/profileInfo", mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.email").value(accountProfile.email)) + .andExpect(jsonPath("$.name").value(accountProfile.name)) + .andExpect(jsonPath("$.image").value(accountProfile.image)) + .andExpect(jsonPath("$.socialType").value(accountProfile.socialType)) + .andExpect(jsonPath("$.fcmToken").value(accountProfile.fcmToken)) + } + + @Test + fun `현재 회원의 리마인드 정보를 조회한다`() { + // given + val remindElements = Account.RemindElements(7, true) + every { accountService.getRemindElements(any()) } returns remindElements + + // when + val resultAction = util.getResultAction("/api/v1/user/remindInfo", mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindCycle").value(remindElements.remindCycle)) + .andExpect(jsonPath("$.remindToggle").value(remindElements.remindToggle)) + } + + @Test + fun `소셜 로그인을 한다`() { + // given + val accountProfile = Account.AccountProfile( + "a@a.com", "Nickname", "https://s3-test.com", "google", "FCMToken" + ) + val accountLoginSuccess = Account.AccountLoginSuccess( + TokenDto("AccessToken", "RefreshToken"), + Account("a@a.com"), + false + ) + every { accountService.oauth2LoginUser(any()) } returns accountLoginSuccess + + // when + val resultAction = util.postResultAction("/api/v1/user/oauth2Login", accountProfile, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.isRegistered").value(accountLoginSuccess.isRegistered)) + .andExpect(jsonPath("$.email").value(accountLoginSuccess.email)) + } + + @Test + fun `토큰을 재발급 한다`() { + // given + val tokenDto = TokenDto("Re-AccessToken", "Re-RefreshToken") + every { accountService.reIssuedAccessToken(any(), any()) } returns tokenDto + + // when + val resultAction = util.getResultAction("/api/v1/user/reIssuanceAccessToken", mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.accessToken").value(tokenDto.accessToken)) + .andExpect(jsonPath("$.refreshToken").value(tokenDto.refreshToken)) + } + + @Test + fun `현재 회원의 프로필 이미지를 변경한다`() { + // TODO: 2022/05/18 + } + + @Test + fun `현재 회원의 프로필을 편집한다`() { + // given + val request = Account.ProfileChanged("change image", "change name") + every { accountService.changeProfile(any(), any()) } just Runs + + // when + val resultAction = util.postResultAction("/api/v1/user/changeProfile", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.SUCCESS)) + } + + @Test + fun `현재 회원의 닉네임을 비교한다`() { + // given + val request = Account.NextNickName("Nickname") + every { accountService.checkNickNameDuplication(any(), any()) } returns Message.AVAILABLE_NAME + + // when + val resultAction = util.postResultAction("/api/v1/user/nickNameCheck", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.AVAILABLE_NAME)) + } + + @Test + fun `현재 회원의 닉네임을 변경한다`() { + // given + val request = Account.NextNickName("Nickname") + every { accountService.changeNickName(any(), any()) } just Runs + + // when + val resultAction = util.postResultAction("/api/v1/user/nickNameChange", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.SUCCESS)) + } + + @Test + fun `현재 회원의 배경 색상을 변경한다`() { + // TODO: 2022/05/18 + } + + @Test + fun `현재 회원의 익스텐션 버전을 조회한다`() { + // given + every { accountService.checkExtension(any()) } returns extensionVersion + println("exten: $extensionVersion") + + // when + val resultAction = util.getResultAction("/api/v1/user/$extensionVersion", mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(extensionVersion)) + } + + @Test + fun `일반 회원가입을 진행한다`() { + // given + val request = AccountRequestDto.SignUpRequest("a@a.com", "1234567!", "token") + val accountLoginSuccess = Account.AccountLoginSuccess( + TokenDto("AccessToken", "RefreshToken"), + Account("a@a.com"), + false + ) + every { accountService.signUp(any()) } returns accountLoginSuccess + + // when + val resultAction = util.postResultAction("/api/v1/user/signUp", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.isRegistered").value(accountLoginSuccess.isRegistered)) + .andExpect(jsonPath("$.email").value(accountLoginSuccess.email)) + } + + @Test + fun `일반 로그인에 성공한다`() { + // given + val request = AccountRequestDto.SignInRequest("a@a.com", "1234567!") + val accountLoginSuccess = Account.AccountLoginSuccess( + TokenDto("AccessToken", "RefreshToken"), + Account("a@a.com"), + false + ) + every { accountService.signIn(any()) } returns accountLoginSuccess + + // when + val resultAction = util.postResultAction("/api/v1/user/signIn", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.isRegistered").value(accountLoginSuccess.isRegistered)) + .andExpect(jsonPath("$.email").value(accountLoginSuccess.email)) + } + + @Test + fun `현재 비밀번호와 입력받은 비밀번호가 동일하면 성공한다`() { + // given + val request = AccountRequestDto.CurrentPassword("1234567!") + every { accountService.comparePassword(any(), any()) } returns Message.SAME_PASSWORD + + // when + val resultAction = util.postResultAction("/api/v1/user/passwordCheck", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.SAME_PASSWORD)) + } + + @Test + fun `비밀번호를 정상적으로 변경한다`() { + // given + val request = AccountRequestDto.PasswordChangeRequest("before1!", "after123!") + every { accountService.changePassword(any(), any()) } returns Message.CHANGE_PASSWORD_SUCCEED + + // when + val resultAction = util.patchResultAction("/api/v1/user/password", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.CHANGE_PASSWORD_SUCCEED)) + } + + @Test + fun `회원을 정상적으로 탈퇴한다`() { + // given + every { accountService.softDelete(any()) } just Runs + + // when + val resultAction = util.deleteResultAction("/api/v1/user/unregister", mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.DELETE_ACCOUNT_SUCCEED)) + } + + @Test + fun `비밀번호 재설정 시 이메일이 존재하는지 확인한다`() { + // given + val request = AccountRequestDto.EmailCheckRequest("a@a.com") + every { accountService.checkEmailExist(any(), any()) } returns Message.SUCCESS_EXIST_EMAIL + + // when + val resultAction = util.postResultAction("/api/v1/user/password/emailCheck", request, mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.SUCCESS_EXIST_EMAIL)) + } + + @Test + fun `비밀번호 재설정 시 임시 비밀번호를 생성하고 메일을 발송한다`() { + // given + val tempPassword = RandomStringUtils.randomAlphanumeric(12) + "!" + every { accountService.createTempPassword() } returns tempPassword + every { accountService.updatePassword(any(), any()) } just Runs + every { accountService.sendMail(any(), tempPassword) } returns Message.SUCCESS_SEND_MAIL + + // when + val resultAction = util.postResultAction("/api/v1/user/password/reset", "", mockMvc) + + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(content().string(Message.SUCCESS_SEND_MAIL)) + } + +} diff --git a/src/test/kotlin/com/yapp/web2/domain/account/service/AccountServiceTest.kt b/src/test/kotlin/com/yapp/web2/domain/account/service/AccountServiceTest.kt index 642a94c4..450191f7 100644 --- a/src/test/kotlin/com/yapp/web2/domain/account/service/AccountServiceTest.kt +++ b/src/test/kotlin/com/yapp/web2/domain/account/service/AccountServiceTest.kt @@ -1,7 +1,9 @@ package com.yapp.web2.domain.account.service +import com.yapp.web2.common.PasswordValidator import com.yapp.web2.config.S3Uploader import com.yapp.web2.domain.account.entity.Account +import com.yapp.web2.domain.account.entity.AccountRequestDto import com.yapp.web2.domain.account.repository.AccountRepository import com.yapp.web2.domain.folder.entity.AccountFolder import com.yapp.web2.domain.folder.entity.Folder @@ -9,19 +11,37 @@ import com.yapp.web2.domain.folder.service.FolderService import com.yapp.web2.exception.BusinessException import com.yapp.web2.exception.custom.AlreadyInvitedException import com.yapp.web2.exception.custom.FolderNotRootException +import com.yapp.web2.exception.custom.PasswordMismatchException import com.yapp.web2.security.jwt.JwtProvider import com.yapp.web2.util.AES256Util +import com.yapp.web2.security.jwt.TokenDto +import io.mockk.MockKAnnotations +import io.mockk.Runs import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.verify import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.mail.javamail.JavaMailSender import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.multipart.MultipartFile -import java.util.* +import java.util.Optional +import kotlin.IllegalStateException @ExtendWith(MockKExtension::class) internal class AccountServiceTest { @@ -43,6 +63,212 @@ internal class AccountServiceTest { @InjectMockKs private lateinit var accountService: AccountService + @MockK + private lateinit var passwordEncoder: PasswordEncoder + + @MockK + private lateinit var mailSender: JavaMailSender + + private lateinit var testAccount: Account + + private lateinit var validator: PasswordValidator + + @BeforeEach + internal fun init() { + MockKAnnotations.init(this) + accountService = AccountService(folderService, accountRepository, jwtProvider, s3Uploader, passwordEncoder, mailSender) + testAccount = Account("test@gmail.com", "1234567!") + validator = PasswordValidator() + } + + @Test + fun `회원 가입할 이메일에서 닉네임을 가져온다`() { + // given + val email = "abc@gmail.com" + + // when + val actual = accountService.getNickName(email) + + // then + assertThat(actual).isEqualTo("abc") + } + + @Test + fun `회원가입에 성공한다`() { + // given + val testToken = TokenDto("testAccessToken", "testRefreshToken") + val request = AccountRequestDto.SignUpRequest("abc@gmail.com", "12341234", "testFcmToken") + val testAccount = Account.signUpToAccount(request, "2b86ff88ef6c4906482731gf15ddcb24381d34b", "abc") + + every { accountRepository.findByEmail(request.email) } returns null + every { passwordEncoder.encode(request.password) } returns "2b86ff88ef6c4906482731gf15ddcb24381d34b" + every { accountRepository.save(any()) } returns testAccount + every { folderService.createDefaultFolder(any()) } just Runs + every { jwtProvider.createToken(any()) } returns testToken + + // when + val actual = accountService.signUp(request) + + // then + assertAll( + { assertThat(actual.accessToken).isEqualTo(testToken.accessToken) }, + { assertThat(actual.refreshToken).isEqualTo(testToken.refreshToken) }, + { assertThat(actual.email).isEqualTo(request.email) }, + { assertThat(actual.isRegistered).isFalse() }, + { assertThat(actual.name).isEqualTo("abc") }, + ) + } + + @Test + fun `회원가입 시 기존 이메일이 존재하면 예외를 반환한다`() { + // given + val request = AccountRequestDto.SignUpRequest("abc@gmail.com", "12341234", "testFcmToken") + every { accountRepository.findByEmail(any()) }.throws(IllegalStateException()) + + // then + org.junit.jupiter.api.assertThrows { accountService.signUp(request) } + } + + @Test + fun `현재 비밀번호와 비교한다`() { + // given + val currentPassword = AccountRequestDto.CurrentPassword("1234567!") + every { jwtProvider.getAccountFromToken(any()) } returns testAccount + every { passwordEncoder.matches(any(), any()) } returns true + + // when + accountService.comparePassword("testToken", currentPassword) + + // then + verify(exactly = 1) { passwordEncoder.matches(any(), any()) } + } + + @Test + fun `비밀번호를 정상적으로 변경한다`() { + // given + val request = AccountRequestDto.PasswordChangeRequest("1234567!", "test") + val expected = "비밀번호가 정상적으로 변경되었습니다." + every { jwtProvider.getAccountFromToken(any()) } returns testAccount + every { passwordEncoder.matches(any(), any()) } returns true + every { passwordEncoder.encode(any()) } returns "test" + + // when + val actual = accountService.changePassword("test", request) + + //then + assertEquals(expected, actual) + } + + @Test + fun `현재 비밀번호와 다를경우 예외를 반환한다`() { + // given + val request = AccountRequestDto.PasswordChangeRequest("1234567!@", "test") + val expectedMessage = "비밀번호가 일치하지 않습니다." + every { jwtProvider.getAccountFromToken(any()) } returns testAccount + every { passwordEncoder.matches(any(), any()) }.throws(PasswordMismatchException()) + + // when + val actualException = assertThrows(PasswordMismatchException::class.java) { + accountService.changePassword("test", request) + } + + //then + assertEquals(expectedMessage, actualException.message) + } + + @ParameterizedTest + @ValueSource(strings = ["1234567!", "a1234567!", "12341234!@", "!1234567", "!abcdefg"]) + fun `비밀번호는 특수문자를 포함하여 영문자 및 숫자 조합으로 8자에서 16자 사이여야 한다`(successPassword: String) { + assertThat(validator.isValid(successPassword, null)).isTrue + } + + @ParameterizedTest + @ValueSource(strings = ["12345678", "abcdefgh", "1234abcd", "1a2b3c4d"]) + fun `특수문자가 포함되지 않은 패스워드는 검증에 실패한다`(failPassword: String) { + assertThat(validator.isValid(failPassword, null)).isFalse + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", "1234", "abcd", "1234567", "123456!", "0123456789abcdefgh"]) + fun `길이가 8자 미만 혹은 16자 초과하는 패스워드는 검증에 실패한다`(failPassword: String) { + assertThat(validator.isValid(failPassword, null)).isFalse + } + + @Test + fun `회원을 정상적으로 탈퇴한다`() { + // given + every { jwtProvider.getAccountFromToken(any()) } returns testAccount + + // when + accountService.softDelete("any") + + // then + assertThat(testAccount.deleted).isTrue + } + + @Test + fun `비밀번호 설정 시 입력한 이메일이 존재하는지 확인한다`() { + // given + val request = AccountRequestDto.EmailCheckRequest("test@gmail.com") + val expected = "입력하신 이메일 주소가 정상적으로 확인되었습니다." + + every { accountRepository.findByEmail(any()) } returns testAccount + + // when + val actual = accountService.checkEmailExist("any", request) + + // then + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `임시 비밀번호를 생성한다`() { + // when + val temp = accountService.createTempPassword() + + // then + assertThat(temp.length).isEqualTo(13) + } + + @Test + fun `이메일이 존재할경우 true를 반환한다`() { + // given + val request = AccountRequestDto.SignUpEmailRequest("test@gmail.com") + every { accountRepository.findByEmail(any()) } returns testAccount + + // when + val actual = accountService.checkEmail(request) + + // then + assertTrue(actual) + } + + @Test + fun `이메일이 존재하지 않을경우 false를 반환한다`() { + // given + val request = AccountRequestDto.SignUpEmailRequest("test@gmail.com") + every { accountRepository.findByEmail(any()) } returns null + + // when + val actual = accountService.checkEmail(request) + + // then + assertFalse(actual) + } + + @Test + fun `FCM Token값을 정상적으로 설정한다`() { + // given + val expected = "test-token" + val request = AccountRequestDto.FcmToken(expected) + every { jwtProvider.getAccountFromToken(any()) } returns testAccount + + // when + accountService.registerFcmToken("token", request) + + // then + assertThat(testAccount.fcmToken).isEqualTo(expected) + } @Nested inner class Profile { @@ -196,7 +422,7 @@ internal class AccountServiceTest { val expectedException = BusinessException("계정이 존재하지 않습니다.") //when - val actualException = assertThrows { + val actualException = assertThrows(BusinessException::class.java) { accountService.changeProfileImage(testToken, testFile) } //then @@ -237,13 +463,13 @@ internal class AccountServiceTest { @Nested inner class BackgroundColorSetting { private lateinit var testToken: String - private lateinit var testBackgroundColorUrl: String + private lateinit var request: AccountRequestDto.ChangeBackgroundColorRequest private lateinit var account: Account @BeforeEach internal fun setUp() { testToken = "testToken" - testBackgroundColorUrl = "http://yapp-bucket-test/test/image" + request = AccountRequestDto.ChangeBackgroundColorRequest("http://yapp-bucket-test/test/image") account = Account("testAccount") } @@ -254,10 +480,10 @@ internal class AccountServiceTest { every { accountRepository.findById(1) } returns Optional.of(account) //when - accountService.changeBackgroundColor(testToken, testBackgroundColorUrl) + accountService.changeBackgroundColor(testToken, request) //then - assertThat(account.backgroundColor).isEqualTo(testBackgroundColorUrl) + assertThat(account.backgroundColor).isEqualTo(request.changeUrl) } } } \ No newline at end of file diff --git a/src/test/kotlin/com/yapp/web2/domain/folder/controller/FolderControllerTest.kt b/src/test/kotlin/com/yapp/web2/domain/folder/controller/FolderControllerTest.kt index ef92816d..36f5398c 100644 --- a/src/test/kotlin/com/yapp/web2/domain/folder/controller/FolderControllerTest.kt +++ b/src/test/kotlin/com/yapp/web2/domain/folder/controller/FolderControllerTest.kt @@ -1,27 +1,25 @@ package com.yapp.web2.domain.folder.controller -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.ninjasquad.springmockk.MockkBean import com.yapp.web2.domain.BaseTimeEntity +import com.yapp.web2.domain.ControllerTestUtil import com.yapp.web2.domain.folder.entity.Folder import com.yapp.web2.domain.folder.service.FolderService import com.yapp.web2.security.jwt.JwtProvider +import com.yapp.web2.util.FolderTokenDto import com.yapp.web2.util.Message import io.mockk.Runs import io.mockk.every import io.mockk.just -import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.http.MediaType 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.MockMvcResultHandlers +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -38,6 +36,8 @@ internal class FolderControllerTest { @MockkBean private lateinit var jwtProvider: JwtProvider + val util = ControllerTestUtil() + @Test fun `폴더를 생성한다`() { // given @@ -47,7 +47,7 @@ internal class FolderControllerTest { every { folderService.createFolder(any(), any()) } returns folder as Folder // when - val resultAction = postResultAction("/api/v1/folder", createFolderRequest) + val resultAction = util.postResultAction("/api/v1/folder", createFolderRequest, mockMvc) // then resultAction @@ -64,7 +64,7 @@ internal class FolderControllerTest { every { folderService.createFolder(any(), any()) } returns folder as Folder // when - val resultAction = postResultAction("/api/v1/folder", createFolderRequest) + val resultAction = util.postResultAction("/api/v1/folder", createFolderRequest, mockMvc) // then resultAction @@ -80,14 +80,13 @@ internal class FolderControllerTest { every { folderService.changeFolder(any(), any()) } just Runs // when - val resultAction = patchResultAction("/api/v1/folder/1", changeFolderRequest) - val response = getResponseBody(resultAction) + val resultAction = util.patchResultAction("/api/v1/folder/1", changeFolderRequest, mockMvc) // then resultAction .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk) - assertThat(response).isEqualTo(Message.SUCCESS) + .andExpect(content().string(Message.SUCCESS)) } @Test @@ -98,14 +97,13 @@ internal class FolderControllerTest { every { folderService.moveFolderByDragAndDrop(any(), any(), any()) } returns Unit // when - val resultAction = patchResultAction("/api/v1/folder/3/move", moveFolderRequest) - val response = getResponseBody(resultAction) + val resultAction = util.patchResultAction("/api/v1/folder/3/move", moveFolderRequest, mockMvc) // then resultAction .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk) - assertThat(response).isEqualTo(Message.SUCCESS) + .andExpect(content().string(Message.SUCCESS)) } @Test @@ -115,14 +113,13 @@ internal class FolderControllerTest { every { folderService.moveFolderByButton(any(), any()) } just Runs // when - val resultAction = patchResultAction("/api/v1/folder/move", moveFolderButtonRequest) - val response = getResponseBody(resultAction) + val resultAction = util.patchResultAction("/api/v1/folder/move", moveFolderButtonRequest, mockMvc) // then resultAction .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk) - assertThat(response).isEqualTo(Message.SUCCESS) + .andExpect(content().string(Message.SUCCESS)) } @DisplayName("폴더의 경우 Hard Delete, 북마크의 경우 Soft Delete") @@ -136,14 +133,13 @@ internal class FolderControllerTest { every { folderService.deleteFolder(any()) } just Runs // when - val resultAction = deleteResultAction("/api/v1/folder/1") - val response = getResponseBody(resultAction) + val resultAction = util.deleteResultAction("/api/v1/folder/1", mockMvc) // then resultAction .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk) - assertThat(response).isEqualTo(Message.SUCCESS) + .andExpect(content().string(Message.SUCCESS)) } @Test @@ -153,7 +149,7 @@ internal class FolderControllerTest { every { folderService.findAll(any()) } returns folders // when - val resultAction = getResultAction("/api/v1/folder") + val resultAction = util.getResultAction("/api/v1/folder", mockMvc) // then resultAction @@ -168,14 +164,13 @@ internal class FolderControllerTest { every { folderService.deleteFolderList(any()) } just Runs // when - val resultAction = postResultAction("/api/v1/folder/deletes", deleteFolderListRequest) - val response = getResponseBody(resultAction) + val resultAction = util.postResultAction("/api/v1/folder/deletes", deleteFolderListRequest, mockMvc) // then resultAction .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk) - assertThat(response).isEqualTo(Message.SUCCESS) + .andExpect(content().string(Message.SUCCESS)) } @Test @@ -188,7 +183,7 @@ internal class FolderControllerTest { every { folderService.findFolderChildList(any()) } returns folderList // when - val resultAction = getResultAction("/api/v1/folder/1/children") + val resultAction = util.getResultAction("/api/v1/folder/1/children", mockMvc) // then resultAction @@ -212,7 +207,7 @@ internal class FolderControllerTest { every { folderService.findAllParentFolderList(any()) } returns folderList // when - val resultAction = getResultAction("/api/v1/folder/1/parent") + val resultAction = util.getResultAction("/api/v1/folder/1/parent", mockMvc) // then resultAction @@ -226,45 +221,19 @@ internal class FolderControllerTest { .andExpect(jsonPath("$[1].name").value("parent2")) } - private fun getResultAction(uri: String): ResultActions { - return mockMvc.perform( - MockMvcRequestBuilders.get(uri) - .header("AccessToken", "token") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - ) - } - - private fun postResultAction(uri: String, request: Any): ResultActions { - return mockMvc.perform( - MockMvcRequestBuilders.post(uri) - .content(jacksonObjectMapper().writeValueAsString(request)) - .header("AccessToken", "token") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - ) - } - - private fun patchResultAction(uri: String, request: Any): ResultActions { - return mockMvc.perform( - MockMvcRequestBuilders.patch(uri) - .content(jacksonObjectMapper().writeValueAsString(request)) - .header("AccessToken", "token") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - ) - } + @Test + fun `암호화된 폴더 ID를 조회한다`() { + // given + val expected = "AES256token" + every { folderService.encryptFolderId(any()) } returns FolderTokenDto(expected) - private fun deleteResultAction(uri: String): ResultActions { - return mockMvc.perform( - MockMvcRequestBuilders.delete(uri) - .header("AccessToken", "token") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - ) - } + // when + val resultAction = util.getResultAction("/api/v1/folder/encrypt/1", mockMvc) - private fun getResponseBody(resultActions: ResultActions): String { - return resultActions.andReturn().response.contentAsString + // then + resultAction + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.folderIdToken").value(expected)) } } \ No newline at end of file diff --git a/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderMoveServiceTest.kt b/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderMoveServiceTest.kt index ff420fae..84394e9b 100644 --- a/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderMoveServiceTest.kt +++ b/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderMoveServiceTest.kt @@ -6,6 +6,7 @@ import com.yapp.web2.domain.bookmark.repository.BookmarkRepository import com.yapp.web2.domain.folder.entity.Folder import com.yapp.web2.domain.folder.repository.FolderRepository import com.yapp.web2.security.jwt.JwtProvider +import com.yapp.web2.util.AES256Util import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK @@ -35,6 +36,9 @@ class FolderMoveServiceTest { @MockK private lateinit var jwtProvider: JwtProvider + @MockK + private lateinit var aeS256Util: AES256Util + private lateinit var user: Account @BeforeEach diff --git a/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderServiceTest.kt b/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderServiceTest.kt index 6107c9c4..ddffbc5d 100644 --- a/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderServiceTest.kt +++ b/src/test/kotlin/com/yapp/web2/domain/folder/service/FolderServiceTest.kt @@ -8,6 +8,7 @@ import com.yapp.web2.domain.bookmark.repository.BookmarkRepository import com.yapp.web2.domain.folder.entity.AccountFolder import com.yapp.web2.domain.folder.entity.Folder import com.yapp.web2.domain.folder.repository.FolderRepository +import com.yapp.web2.exception.custom.AccountNotFoundException import com.yapp.web2.exception.custom.FolderNotFoundException import com.yapp.web2.security.jwt.JwtProvider import com.yapp.web2.util.AES256Util @@ -27,6 +28,7 @@ import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.springframework.data.repository.findByIdOrNull import java.util.Optional +import javax.swing.text.html.Option @ExtendWith(MockKExtension::class) internal open class FolderServiceTest { @@ -79,6 +81,17 @@ internal open class FolderServiceTest { verify(exactly = 1) { folderRepository.save(any()) } } + @Test + fun `폴더 생성 시 account가 존재하지 않으면 예외가 발생한다`() { + // given + val request = Folder.FolderCreateRequest(name = "Root Folder") + every { jwtProvider.getIdFromToken(any()) } returns 1L + every { accountRepository.findById(any()) } returns Optional.ofNullable(null) + + // then + assertThrows { folderService.createFolder(request, "test") } + } + @Test fun `부모 폴더가 존재하지 않는 최상위 폴더를 생성한다`() { // given @@ -149,16 +162,136 @@ internal open class FolderServiceTest { ) } - // TODO: 2022/04/03 @Test - fun `폴더를 드래그 앤 드랍 이동한다`() { + fun `0번째 보관함에서 3번째 보관함으로 폴더를 드래그 앤 드랍으로 이동한다`() { + // given + val moveFolder = Folder("Move Folder", 0, parentFolder = null) + val request = Folder.FolderMoveRequest("root", 2) + val topFolderList = getParentFolderList() + + every { jwtProvider.getIdFromToken(any()) } returns 1L + every { accountRepository.findById(any()).get() } returns user + every { folderRepository.findById(any()).get() } returns moveFolder + every { folderRepository.findAllByParentFolderIsNull(any()) } returns topFolderList + + // when + folderService.moveFolderByDragAndDrop(1L, request, "token") + + // then : topFolderList Index - 1,3,4 -> 0,3,4 + assertAll( + { assertThat(moveFolder.index).isEqualTo(2) }, + { assertThat(topFolderList[0].index).isEqualTo(0)}, + { assertThat(topFolderList[1].index).isEqualTo(3) }, + { assertThat(topFolderList[2].index).isEqualTo(4) } + ) + } + + @Test + fun `3번째 보관함에서 0번째 보관함으로 폴더를 드래그 앤 드랍으로 이동한다`() { + // given + val moveFolder = Folder("Move Folder", 2, parentFolder = null) + val request = Folder.FolderMoveRequest("root", 0) + val topFolderList = getParentFolderList() + + every { jwtProvider.getIdFromToken(any()) } returns 1L + every { accountRepository.findById(any()).get() } returns user + every { folderRepository.findById(any()).get() } returns moveFolder + every { folderRepository.findAllByParentFolderIsNull(any()) } returns topFolderList + + // when + folderService.moveFolderByDragAndDrop(1L, request, "token") + + // then : topFolderList Index - 1,3,4 -> 2,3,4 + assertAll( + { assertThat(moveFolder.index).isEqualTo(0) }, + { assertThat(topFolderList[0].index).isEqualTo(2)}, + { assertThat(topFolderList[1].index).isEqualTo(3) }, + { assertThat(topFolderList[2].index).isEqualTo(4) } + ) + } + + @Test + fun `이동하기 전 폴더가 존재하지 않을 경우 예외가 발생한다`() { + // given + val request = Folder.FolderMoveButtonRequest(mutableListOf(1L), 2L) + every { jwtProvider.getIdFromToken(any()) } returns 1L + every { accountRepository.findById(any()).get() } returns user + every { folderRepository.findById(any()) } returns Optional.ofNullable(null) + + // then + assertThrows { folderService.moveFolderByButton("token", request) } + } + + @Test + fun `이동 하려는 폴더에 대한 정보가 존재하지 않을 경우 예외가 발생한다`() { + // given + val request = Folder.FolderMoveButtonRequest(mutableListOf(1L), 2L) + every { jwtProvider.getIdFromToken(any()) } returns 1L + every { accountRepository.findById(any()).get() } returns user + every { folderRepository.findById(1L) } returns Optional.of(folder) // beforeFolder + every { folderRepository.findAllByParentFolderIsNull(any()) } returns mutableListOf() + every { folderRepository.findById(request.nextFolderId) } returns Optional.ofNullable(null) // nextFolder + + // then + assertThrows { folderService.moveFolderByButton("token", request) } + } + + @Test + fun `버튼을 클릭하여 0번째 폴더를 2번째 폴더로 이동한다`() { + // given + val beforeFolder = Folder("Before Folder", 0, parentFolder = null) + val nextFolder = Folder("Next Folder", 2, parentFolder = null) + beforeFolder.id = 1L + nextFolder.id = 2L + val beforeChildren = getParentFolderList() + val request = Folder.FolderMoveButtonRequest(mutableListOf(1L), 2L) + + every { jwtProvider.getIdFromToken(any()) } returns 1L + every { accountRepository.findById(any()).get() } returns user + every { folderRepository.findById(1L) } returns Optional.of(beforeFolder) + every { folderRepository.findById(2L) } returns Optional.of(nextFolder) + every { folderRepository.findAllByParentFolderIsNull(any()) } returns beforeChildren + every { folderRepository.save(any()) } returns folder + + // when + folderService.moveFolderByButton("token", request) + + // then : 이동 전 폴더 리스트에서 자신보다 index가 큰 폴더들의 index가 1씩 감소한다 + assertAll( + { assertThat(beforeChildren[0].index).isEqualTo(0)}, + { assertThat(beforeChildren[1].index).isEqualTo(2) }, + { assertThat(beforeChildren[2].index).isEqualTo(3) } + ) + } + + private fun getParentFolderList(): MutableList { + return mutableListOf( + Folder("Folder1", 1, parentFolder = null), + Folder("Folder2", 3, parentFolder = null), + Folder("Folder3", 4, parentFolder = null) + ) + } + + @Test + fun `folderId로 폴더를 조회한다`() { + // given + folder.id = 1L + every { folderRepository.findById(1L) } returns Optional.of(folder) + // when + val actual = folderService.findByFolderId(1L) + + // then + assertThat(actual).isEqualTo(folder) } - // TODO: 2022/04/03 @Test - fun `버튼 클릭에 의해 폴더가 이동된다`() { + fun `폴더 조회 시 존재하지 않는 folderId일 경우 예외를 반환한다`() { + // given + every { folderRepository.findById(any()) } returns Optional.ofNullable(null) + // then + assertThrows { folderService.findByFolderId(1L) } } @Test @@ -185,6 +318,25 @@ internal open class FolderServiceTest { assertThrows { folderService.changeFolder(1L, changeRequest) } } + @Test + fun `부모 폴더와 자식 폴더들을 전부 삭제한다`() { + // given + val parentFolder: Folder = getParentFolder("parent") + parentFolder.id = 1L + val childFolders: MutableList = getChildFolders(parentFolder, 1, 5) + val bookmark = makeBookmarks() + + every { bookmarkRepository.findByFolderId(any()) } returns bookmark + every { folderRepository.deleteByFolder(any()) } just Runs + every { bookmarkRepository.save(any()) } returns Bookmark(1L, 1L, "test") + + // when + folderService.deleteFolderRecursive(parentFolder) + + // then + verify(exactly = childFolders.size + 1) { folderRepository.deleteByFolder(any()) } + } + @Test fun `전체 폴더를 조회하고 출력한다`() { // given @@ -208,13 +360,28 @@ internal open class FolderServiceTest { printJson(actual) } + @Test + fun `폴더 id를 정상적으로 암호화한다`() { + // given + val expected = "YanblGzXpM13KWrqVqhMYA==" + folder.id = 1L + every { folderRepository.findFolderById(any()) } returns folder + every { aeS256Util.encrypt(any()) } returns expected + + // when + val actual = folderService.encryptFolderId(folder.id!!) + + // then + assertThat(actual.folderIdToken).isEqualTo(expected) + } + @Test fun `폴더 id를 암호화할 때, 폴더 id가 존재하지 않으면 예외를 던진다`() { // given - folder.id = 1 + folder.id = 1L every { folderRepository.findFolderById(folder.id!!) } returns null - // then & + // then assertThrows { folderService.encryptFolderId(folder.id!!) } }