diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0d8a345 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DB_HOST= +DB_PORT= +DB_NAME= +DB_USERNAME= +DB_PASSWORD= +MYSQL_ROOT_PASSWORD= +REDIS_HOST= +REDIS_PORT= +JWT_SECRET= \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..5dfe70a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,29 @@ +--- +name: Bug +about: 버그 또는 예상하지 못한 동작을 수정하기 위한 이슈입니다. +title: "[Bug] " +labels: "bug" +assignees: "" +--- + +## 문제 상황 + +발생한 문제를 작성해주세요. + +## 재현 방법 + +1. +2. +3. + +## 기대 동작 + +정상적으로 기대했던 동작을 작성해주세요. + +## 실제 동작 + +실제로 발생한 동작을 작성해주세요. + +## 참고 사항 + +에러 로그, 스크린샷 등 문제 파악에 도움이 되는 내용이 있다면 첨부해주세요. diff --git a/.github/ISSUE_TEMPLATE/chore.md b/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..b9e9b29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,21 @@ +--- +name: Chore +about: 환경 설정, 인프라, 문서 등의 작업을 위한 이슈입니다. +title: "[Chore] " +labels: "chore" +assignees: "" +--- + +## 작업 내용 + +환경 설정, 인프라, 문서 등의 작업 내용을 작성해주세요. + +## 세부 작업 + +- [ ] 작업 1 +- [ ] 작업 2 +- [ ] 작업 3 + +## 참고 사항 + +추가적으로 공유할 내용이 있다면 작성해주세요. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8005e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..019347a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,21 @@ +--- +name: Feature +about: 새로운 기능 구현을 위한 이슈입니다. +title: "[Feature] " +labels: "feature" +assignees: "" +--- + +## 작업 내용 + +구현할 기능 또는 작업 내용을 작성해주세요. + +## 세부 작업 + +- [ ] 작업 1 +- [ ] 작업 2 +- [ ] 작업 3 + +## 참고 사항 + +관련 문서, 이미지, 논의 내용 등이 있다면 작성해주세요. diff --git a/.github/ISSUE_TEMPLATE/refactor.md b/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1e09512 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,25 @@ +--- +name: Refactor +about: 기능 변경 없이 코드 또는 구조를 개선하기 위한 이슈입니다. +title: "[Refactor] " +labels: "refactor" +assignees: "" +--- + +## 리팩토링 대상 + +리팩토링할 코드 또는 구조를 작성해주세요. + +## 개선 이유 + +현재 구조의 문제점 또는 개선하려는 이유를 작성해주세요. + +## 세부 작업 + +- [ ] 작업 1 +- [ ] 작업 2 +- [ ] 작업 3 + +## 참고 사항 + +추가적으로 공유할 내용이 있다면 작성해주세요. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..47c5ed8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## 🛠️ 주요 변경 사항 +> 이번 PR에서 작업한 내용을 간략히 요약해주세요. (기능 추가, 버그 수정, 리팩토링 등) +- + + +## ✨ 작업 내용 +> 코드에서 변경된 핵심 내용을 설명해주세요. +- + + +## ⚠️ 주의 사항 및 특이사항 +> 리뷰어가 특별히 참고해야 할 부분이나 고민했던 점이 있다면 적어주세요. +- [ ] (예: DB 인덱스 추가 필요, 특정 API 성능 테스트 여부 등) + + +## 💡 관련 이슈 (Optional) +> 연관된 이슈 번호를 작성해주세요. (예: Close #123) +- + + +## 🧪 테스트 결과 (Optional) +> 기능이 정상 동작하는지 확인한 테스트 결과(포스트맨, 단위 테스트 등)를 첨부하거나 설명해주세요. +- [ ] 단위 테스트 통과 +- [ ] 통합 테스트 통과 \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..0fd27a9 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,184 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +# OIDC 토큰 요청 권한 +permissions: + id-token: write # OIDC 토큰 요청용 + contents: read # 코드 체크아웃용 (체크아웃 - Repo에 있는 코드를 Runner에 옮김) + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.vars.outputs.short_sha }} + + steps: + # Step 1: 코드 가져오기 + - name: Checkout code + uses: actions/checkout@v6 + + # Step 2: 커밋 해시 추출 + - name: Set short SHA + id: vars + run: echo "short_sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + # Step 3: 스프링 빌드를 위한 Java 21 설치 + - name: Setup Java 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'corretto' + + # Step 4: Gradle 캐시 + - name: Cache Gradle packages + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # Step 5: 테스트 실행 + - name: Run tests + run: ./gradlew test + + # Step 6: JAR 빌드 + - name: Build JAR + run: ./gradlew bootJar + + # Step 7: AWS 자격증명 - OIDC + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + # Step 8: ECR 로그인 + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + # Step 9: Docker 빌드 및 push + - name: Build and push Docker image + run: | + SHORT_SHA=${{ steps.vars.outputs.short_sha }} + IMAGE_URI=${{ vars.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/${{ vars.ECR_REPOSITORY }} + + # Docker 빌드 및 push + docker build -t $IMAGE_URI:$SHORT_SHA . +# docker build --platform linux/arm64 -t $IMAGE_URI:$SHORT_SHA . + docker push $IMAGE_URI:$SHORT_SHA + + echo "✅ Pushed: $IMAGE_URI:$SHORT_SHA" + +# deploy: +# needs: build +# if: github.ref == 'refs/heads/main' # main push에서만 배포 (PR에서는 CI만 실행) +# runs-on: ubuntu-latest +# +# steps: +# # Step 1: AWS 자격증명 - OIDC +# - name: Configure AWS credentials +# uses: aws-actions/configure-aws-credentials@v6 +# with: +# role-to-assume: ${{ vars.AWS_ROLE_ARN }} +# aws-region: ${{ vars.AWS_REGION }} +# +# # Step 2: Launch Template 새 버전 생성 (User Data의 IMAGE_TAG를 새 커밋 SHA로 교체) +# - name: Update Launch Template +# env: +# IMAGE_TAG: ${{ needs.build.outputs.image_tag }} +# AWS_ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID }} +# AWS_REGION: ${{ vars.AWS_REGION }} +# ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }} +# LAUNCH_TEMPLATE_ID: ${{ vars.LAUNCH_TEMPLATE_ID }} +# run: | +# ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}" +# +# USER_DATA=$(cat <<'USERDATA' | sed "s|__IMAGE_TAG__|${IMAGE_TAG}|g; s|__AWS_ACCOUNT_ID__|${AWS_ACCOUNT_ID}|g; s|__AWS_REGION__|${AWS_REGION}|g; s|__ECR_REPOSITORY__|${ECR_REPOSITORY}|g" | base64 -w 0 +# #!/bin/bash +# set -e +# AWS_REGION="__AWS_REGION__" +# AWS_ACCOUNT_ID="__AWS_ACCOUNT_ID__" +# ECR_REPOSITORY="__ECR_REPOSITORY__" +# IMAGE_TAG="__IMAGE_TAG__" +# ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}" +# dnf update -y +# dnf install -y docker +# systemctl enable docker +# systemctl start docker +# aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com +# docker pull ${ECR_URI}:${IMAGE_TAG} +# docker run -d -p 8080:8080 --restart unless-stopped --name ci-demo -e SPRING_PROFILES_ACTIVE=prod ${ECR_URI}:${IMAGE_TAG} +# USERDATA +# ) +# +# NEW_VERSION=$(aws ec2 create-launch-template-version \ +# --launch-template-id ${LAUNCH_TEMPLATE_ID} \ +# --source-version '$Latest' \ +# --launch-template-data "{\"UserData\":\"${USER_DATA}\"}" \ +# --query 'LaunchTemplateVersion.VersionNumber' \ +# --output text) +# +# echo "✅ Launch Template v${NEW_VERSION} 생성 완료" +# +# aws ec2 modify-launch-template \ +# --launch-template-id ${LAUNCH_TEMPLATE_ID} \ +# --default-version ${NEW_VERSION} +# +# echo "✅ 기본 버전 → v${NEW_VERSION} 변경 완료" +# +# # Step 3: Instance Refresh 시작 +# - name: Start Instance Refresh +# id: refresh +# env: +# ASG_NAME: ${{ vars.ASG_NAME }} +# run: | +# REFRESH_ID=$(aws autoscaling start-instance-refresh \ +# --auto-scaling-group-name ${ASG_NAME} \ +# --preferences '{ +# "MinHealthyPercentage": 100, +# "InstanceWarmup": 120 +# }' \ +# --query 'InstanceRefreshId' \ +# --output text) +# +# echo "refresh_id=${REFRESH_ID}" >> $GITHUB_OUTPUT +# echo "✅ Instance Refresh 시작: ${REFRESH_ID}" +# +# # Step 4: Instance Refresh 완료 대기 +# - name: Wait for Instance Refresh +# env: +# ASG_NAME: ${{ vars.ASG_NAME }} +# REFRESH_ID: ${{ steps.refresh.outputs.refresh_id }} +# run: | +# echo "⏳ Instance Refresh 완료 대기 중..." +# +# for i in $(seq 1 30); do +# STATUS=$(aws autoscaling describe-instance-refreshes \ +# --auto-scaling-group-name ${ASG_NAME} \ +# --instance-refresh-ids ${REFRESH_ID} \ +# --query 'InstanceRefreshes[0].Status' \ +# --output text) +# +# echo "시도 ${i}/30 - 상태: ${STATUS}" +# +# if [ "${STATUS}" = "Successful" ]; then +# echo "✅ Instance Refresh 성공! 배포 완료!" +# exit 0 +# elif [ "${STATUS}" = "Failed" ] || [ "${STATUS}" = "Cancelled" ]; then +# echo "❌ Instance Refresh 실패: ${STATUS}" +# exit 1 +# fi +# +# sleep 30 +# done +# +# echo "❌ Instance Refresh 타임아웃 (15분 초과)" +# exit 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..deb9ae9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ out/ ### VS Code ### .vscode/ + +### ENV ### +.env +.env.* +!.env.example +!.env.example.DS_Store +performance/reports/*.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d88470b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/*.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9a77bb0..7343007 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,8 @@ group = 'com.dropit' version = '0.0.1-SNAPSHOT' description = 'dropit-server' +def querydslVersion = '7.5' + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -21,10 +23,33 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:4.1.0') + implementation 'io.awspring.cloud:spring-cloud-aws-starter-parameter-store' + + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa" + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-security-test' + testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test' + testImplementation 'org.testcontainers:testcontainers-mysql' + testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' @@ -33,3 +58,15 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.register('dbUp', Exec) { + group = 'development' + description = '로컬 DB 컨테이너를 실행합니다.' + commandLine 'docker', 'compose', '--env-file', '.env.local', 'up', '-d' +} + +tasks.register('dbDown', Exec) { + group = 'development' + description = '로컬 DB 컨테이너를 종료합니다.' + commandLine 'docker', 'compose', '--env-file', '.env.local', 'down' +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4978f23 --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +name: dropit + +services: + db: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + ports: + - "127.0.0.1:${DB_PORT}:3306" + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: [ "CMD-SHELL", 'mysqladmin ping -h localhost -u root --password="$$MYSQL_ROOT_PASSWORD"' ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7.4-alpine + ports: + - "127.0.0.1:${REDIS_PORT}:6379" + volumes: + - redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + mysql-data: + redis-data: \ No newline at end of file diff --git a/src/main/java/com/dropit/DropitServerApplication.java b/src/main/java/com/dropit/DropitServerApplication.java index 1aed907..b10bd26 100644 --- a/src/main/java/com/dropit/DropitServerApplication.java +++ b/src/main/java/com/dropit/DropitServerApplication.java @@ -1,9 +1,15 @@ package com.dropit; +import com.dropit.global.security.jwt.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@SpringBootApplication +@EnableJpaAuditing +@EnableConfigurationProperties(JwtProperties.class) +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) public class DropitServerApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dropit/auth/controller/AuthController.java b/src/main/java/com/dropit/auth/controller/AuthController.java new file mode 100644 index 0000000..51c5909 --- /dev/null +++ b/src/main/java/com/dropit/auth/controller/AuthController.java @@ -0,0 +1,49 @@ +package com.dropit.auth.controller; + +import com.dropit.auth.dto.request.LoginRequest; +import com.dropit.auth.dto.request.ReissueRequest; +import com.dropit.auth.dto.request.SignupRequest; +import com.dropit.auth.dto.response.TokenResponse; +import com.dropit.auth.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup( + @Valid @RequestBody SignupRequest request + ) { + authService.signup(request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .build(); + } + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody LoginRequest request + ) { + return ResponseEntity.ok( + authService.login(request) + ); + } + + @PostMapping("/reissue") + public ResponseEntity reissue( + @Valid @RequestBody ReissueRequest request + ) { + return ResponseEntity.ok( + authService.reissue(request) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/LoginRequest.java b/src/main/java/com/dropit/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..46134a5 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/LoginRequest.java @@ -0,0 +1,15 @@ +package com.dropit.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이어야 합니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/ReissueRequest.java b/src/main/java/com/dropit/auth/dto/request/ReissueRequest.java new file mode 100644 index 0000000..dda06d8 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/ReissueRequest.java @@ -0,0 +1,10 @@ +package com.dropit.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record ReissueRequest( + + @NotBlank(message = "Refresh Token은 필수입니다.") + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/SignupRequest.java b/src/main/java/com/dropit/auth/dto/request/SignupRequest.java new file mode 100644 index 0000000..7e5c49f --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/SignupRequest.java @@ -0,0 +1,21 @@ +package com.dropit.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record SignupRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이어야 합니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 255, message = "비밀번호는 8자 이상이어야 합니다.") + String password, + + @NotBlank(message = "사용자 이름은 필수입니다.") + @Size(min = 1, max = 100, message = "사용자 이름은 1자 이상 100자 이하여야 합니다.") + String username +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/response/TokenResponse.java b/src/main/java/com/dropit/auth/dto/response/TokenResponse.java new file mode 100644 index 0000000..3f94715 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/response/TokenResponse.java @@ -0,0 +1,16 @@ +package com.dropit.auth.dto.response; + +import com.dropit.global.security.jwt.JwtTokenPair; + +public record TokenResponse( + String accessToken, + String refreshToken +) { + + public static TokenResponse from(JwtTokenPair tokenPair) { + return new TokenResponse( + tokenPair.accessToken(), + tokenPair.refreshToken() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/exception/AuthErrorCode.java b/src/main/java/com/dropit/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..e99abcc --- /dev/null +++ b/src/main/java/com/dropit/auth/exception/AuthErrorCode.java @@ -0,0 +1,32 @@ +package com.dropit.auth.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + INVALID_CREDENTIALS( + HttpStatus.UNAUTHORIZED, + "이메일 또는 비밀번호가 올바르지 않습니다." + ), + + INVALID_REFRESH_TOKEN( + HttpStatus.UNAUTHORIZED, + "Refresh Token이 올바르지 않습니다." + ); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/service/AuthService.java b/src/main/java/com/dropit/auth/service/AuthService.java new file mode 100644 index 0000000..c4da670 --- /dev/null +++ b/src/main/java/com/dropit/auth/service/AuthService.java @@ -0,0 +1,115 @@ +package com.dropit.auth.service; + +import com.dropit.auth.dto.request.LoginRequest; +import com.dropit.auth.dto.request.ReissueRequest; +import com.dropit.auth.dto.request.SignupRequest; +import com.dropit.auth.dto.response.TokenResponse; +import com.dropit.auth.exception.AuthErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.jwt.JwtTokenPair; +import com.dropit.global.security.jwt.JwtUtil; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.exception.UserErrorCode; +import com.dropit.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + @Transactional + public void signup(SignupRequest request) { + + if (userRepository.existsByEmail(request.email())) { + throw new ServiceException( + UserErrorCode.EMAIL_ALREADY_EXISTS + ); + } + + if (userRepository.existsByUsername(request.username())) { + throw new ServiceException( + UserErrorCode.USERNAME_ALREADY_EXISTS + ); + } + + User user = new User( + request.email(), + passwordEncoder.encode(request.password()), + request.username(), + UserRole.USER + ); + + userRepository.save(user); + } + + @Transactional(readOnly = true) + public TokenResponse login(LoginRequest request) { + + User user = userRepository + .findByEmailAndDeletedAtIsNull(request.email()) + .orElseThrow(() -> + new ServiceException( + AuthErrorCode.INVALID_CREDENTIALS + ) + ); + + if (!passwordEncoder.matches( + request.password(), + user.getPassword() + )) { + throw new ServiceException( + AuthErrorCode.INVALID_CREDENTIALS + ); + } + + JwtTokenPair tokenPair = + jwtUtil.issueTokenPair(user); + + return TokenResponse.from(tokenPair); + } + + @Transactional(readOnly = true) + public TokenResponse reissue(ReissueRequest request) { + + Claims claims = + jwtUtil.getRefreshTokenClaims( + request.refreshToken() + ); + + Long userId = parseUserId( + claims.getSubject() + ); + + User user = userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> + new ServiceException( + AuthErrorCode.INVALID_REFRESH_TOKEN + ) + ); + + JwtTokenPair tokenPair = + jwtUtil.issueTokenPair(user); + + return TokenResponse.from(tokenPair); + } + + private Long parseUserId(String subject) { + try { + return Long.valueOf(subject); + } catch (NumberFormatException | NullPointerException e) { + throw new ServiceException( + AuthErrorCode.INVALID_REFRESH_TOKEN + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java new file mode 100644 index 0000000..4dd4d01 --- /dev/null +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -0,0 +1,117 @@ +package com.dropit.drop.controller; + +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.service.DropService; +import com.dropit.global.security.principal.CurrentUserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@RestController +@RequiredArgsConstructor +public class DropController { + + private final DropService dropService; + + @PostMapping("/drops") + public ResponseEntity create( + @CurrentUserId Long sellerId, + @Valid @RequestBody DropCreateRequest request + ) { + Long dropId = dropService.save(sellerId, request); + + return ResponseEntity + .created(URI.create("/drops/" + dropId)) + .body(dropId); + } + + @GetMapping("/drops") + public ResponseEntity> getAll( + @ModelAttribute DropSearchCondition condition, + @PageableDefault(size = 20) Pageable pageable + ) { + Page response = dropService.getAll(condition, pageable); + + return ResponseEntity.ok(response); + } + + @GetMapping("/drops/{dropId}") + public ResponseEntity getOne( + @PathVariable Long dropId + ) { + return ResponseEntity.ok(dropService.getOne(dropId)); + } + + @GetMapping("/creators/{sellerId}/drops") + public ResponseEntity> getPublicDropsBySeller( + @PathVariable Long sellerId, + @PageableDefault( + size = 20, + sort = "createdAt", + direction = Sort.Direction.DESC + ) Pageable pageable + ) { + Page response = dropService.getPublicDropsBySeller( + sellerId, + pageable + ); + + return ResponseEntity.ok(response); + } + + @GetMapping("/users/me/drops") + public ResponseEntity> getDropsForSellerManagement( + @CurrentUserId Long sellerId, + @PageableDefault( + size = 20, + sort = "createdAt", + direction = Sort.Direction.DESC + ) Pageable pageable + ) { + Page response = dropService.getDropsForSellerManagement( + sellerId, + pageable + ); + + return ResponseEntity.ok(response); + } + + @PatchMapping("/drops/{dropId}") + public ResponseEntity update( + @CurrentUserId Long sellerId, + @PathVariable Long dropId, + @Valid @RequestBody DropUpdateRequest request + ) { + return ResponseEntity.ok(dropService.update(sellerId, dropId, request)); + } + + @PatchMapping("/drops/{dropId}/visibility") + public ResponseEntity changeVisibility( + @CurrentUserId Long sellerId, + @PathVariable Long dropId, + @Valid @RequestBody DropVisibilityUpdateRequest request + ) { + return ResponseEntity.ok(dropService.changeVisibility(sellerId, dropId, request)); + } + + @DeleteMapping("/drops/{dropId}") + public ResponseEntity delete( + @CurrentUserId Long sellerId, + @PathVariable Long dropId + ) { + dropService.delete(sellerId, dropId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java new file mode 100644 index 0000000..127df32 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java @@ -0,0 +1,34 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record DropCreateRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @NotNull(message = "가격은 필수입니다.") + @Positive(message = "가격은 0보다 커야 합니다.") + @Digits(integer = 13, fraction = 0, message = "가격은 원 단위 정수로 입력해야 합니다.") + BigDecimal price, + + @Positive(message = "초기 수량은 1개 이상이어야 합니다.") + int initialQuantity, + + @Min(value = 0, message = "할인율은 0 이상이어야 합니다.") + @Max(value = 100, message = "할인율은 100 이하여야 합니다.") + int discountRate, + + @NotNull(message = "구매 제한 수량은 필수입니다.") + @PositiveOrZero(message = "구매 제한 수량은 0개 이상이어야 합니다.") + Integer purchaseLimit, + + @NotNull(message = "판매 시작 시각은 필수입니다.") + LocalDateTime openAt, + + @NotNull(message = "판매 종료 시각은 필수입니다.") + LocalDateTime closeAt +) { +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java b/src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java new file mode 100644 index 0000000..a0cd2e9 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java @@ -0,0 +1,10 @@ +package com.dropit.drop.dto.request; + +import com.dropit.drop.entity.DropStatus; + +public record DropSearchCondition( + String keyword, + DropStatus status, + DropSortType sortType +) { +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropSortType.java b/src/main/java/com/dropit/drop/dto/request/DropSortType.java new file mode 100644 index 0000000..72beb5f --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropSortType.java @@ -0,0 +1,8 @@ +package com.dropit.drop.dto.request; + +public enum DropSortType { + + LATEST, + PRICE_ASC, + CLOSING_SOON +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java new file mode 100644 index 0000000..ad56931 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java @@ -0,0 +1,27 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record DropUpdateRequest( + @Positive(message = "가격은 0보다 커야 합니다.") + @Digits(integer = 13, fraction = 0, message = "가격은 원 단위 정수로 입력해야 합니다.") + BigDecimal price, + + @Positive(message = "초기 수량은 1개 이상이어야 합니다.") + Integer initialQuantity, + + @Min(value = 0, message = "할인율은 0 이상이어야 합니다.") + @Max(value = 100, message = "할인율은 100 이하여야 합니다.") + Integer discountRate, + + @PositiveOrZero(message = "구매 제한 수량은 0개 이상이어야 합니다.") + Integer purchaseLimit, + + LocalDateTime openAt, + + LocalDateTime closeAt +) { +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java new file mode 100644 index 0000000..bc655a0 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java @@ -0,0 +1,9 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record DropVisibilityUpdateRequest( + @NotNull(message = "공개 여부는 필수입니다.") + Boolean visible +) { +} diff --git a/src/main/java/com/dropit/drop/dto/response/DropResponse.java b/src/main/java/com/dropit/drop/dto/response/DropResponse.java new file mode 100644 index 0000000..04a9833 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/response/DropResponse.java @@ -0,0 +1,61 @@ +package com.dropit.drop.dto.response; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.entity.DropStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record DropResponse( + Long id, + Long sellerId, + String sellerName, + + Long productId, + String productName, + String imageUrl, + + BigDecimal price, + int discountRate, + + int initialQuantity, + int remainingQuantity, + int soldQuantity, + + int purchaseLimit, + + boolean visible, + + LocalDateTime openAt, + LocalDateTime closeAt, + + DropStatus status +) { + public static DropResponse from(Drop drop) { + return new DropResponse( + drop.getId(), + drop.getProduct().getSeller().getId(), + drop.getProduct().getSeller().getUsername(), + + drop.getProduct().getId(), + drop.getProduct().getName(), + drop.getProduct().getImageUrl(), + + drop.getPrice(), + drop.getDiscountRate(), + + drop.getInitialQuantity(), + drop.getRemainingQuantity(), + drop.getInitialQuantity() - drop.getRemainingQuantity(), + + drop.getPurchaseLimit(), + + drop.isVisible(), + + drop.getOpenAt(), + drop.getCloseAt(), + + drop.currentStatus(LocalDateTime.now()) + ); + } +} diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java new file mode 100644 index 0000000..35d05cf --- /dev/null +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -0,0 +1,137 @@ +package com.dropit.drop.entity; + +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.global.entity.BaseEntity; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "drops") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Drop extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(nullable = false, precision = 13, scale = 0) + private BigDecimal price; + + @Column(name = "initial_quantity", nullable = false) + private int initialQuantity; + + @Column(name = "remaining_quantity", nullable = false) + private int remainingQuantity; + + @Column(name = "discount_rate", nullable = false) + private int discountRate; // 0이면 할인 없음 + + @Column(name = "purchase_limit", nullable = false) + private int purchaseLimit; // 0이면 1인당 구매 제한 없음 + + @Column(nullable = false) + private boolean visible = false; + + @Column(name = "open_at", nullable = false) + private LocalDateTime openAt; + + @Column(name = "close_at", nullable = false) + private LocalDateTime closeAt; + + public Drop( + Product product, + BigDecimal price, + int initialQuantity, + int discountRate, + int purchaseLimit, + LocalDateTime openAt, + LocalDateTime closeAt + ) { + validatePeriod(openAt, closeAt); + + this.product = product; + this.price = price; + this.initialQuantity = initialQuantity; + this.remainingQuantity = initialQuantity; + this.discountRate = discountRate; + this.purchaseLimit = purchaseLimit; + this.openAt = openAt; + this.closeAt = closeAt; + } + + public DropStatus currentStatus(LocalDateTime now) { + if (!now.isBefore(closeAt)) return DropStatus.CLOSED; + if (now.isBefore(openAt)) return DropStatus.READY; + if (remainingQuantity == 0) return DropStatus.SOLDOUT; + + // openAt <= now < closeAt + return DropStatus.OPEN; + } + + public void update( + BigDecimal price, + Integer initialQuantity, + Integer discountRate, + Integer purchaseLimit, + LocalDateTime openAt, + LocalDateTime closeAt + ) { + int updatedInitialQuantity = initialQuantity != null ? initialQuantity : this.initialQuantity; + LocalDateTime updatedOpenAt = openAt != null ? openAt : this.openAt; + LocalDateTime updatedCloseAt = closeAt != null ? closeAt : this.closeAt; + validatePeriod(updatedOpenAt, updatedCloseAt); + + if (this.remainingQuantity == this.initialQuantity) { + this.remainingQuantity = updatedInitialQuantity; + } + this.initialQuantity = updatedInitialQuantity; + this.price = price != null ? price : this.price; + this.discountRate = discountRate != null ? discountRate : this.discountRate; + this.purchaseLimit = purchaseLimit != null ? purchaseLimit : this.purchaseLimit; + this.openAt = updatedOpenAt; + this.closeAt = updatedCloseAt; + } + + private static void validatePeriod(LocalDateTime openAt, LocalDateTime closeAt) { + if (!openAt.isBefore(closeAt)) { + throw new ServiceException(DropErrorCode.INVALID_DROP_PERIOD); + } + } + + public void ensureEditable(LocalDateTime now) { + if (!now.isBefore(openAt)) { + throw new ServiceException(DropErrorCode.DROP_ALREADY_STARTED); + } + } + + public void changeVisibility(boolean visible) { + this.visible = visible; + } + + public void decreaseStock(int quantity, LocalDateTime now) { + if (!visible || currentStatus(now) != DropStatus.OPEN) { + throw new ServiceException(DropErrorCode.DROP_NOT_OPEN); + } + if (remainingQuantity < quantity) { + throw new ServiceException(DropErrorCode.INSUFFICIENT_STOCK); + } + + this.remainingQuantity -= quantity; + } + + public void restoreStock(int quantity) { + this.remainingQuantity += quantity; + } +} diff --git a/src/main/java/com/dropit/drop/entity/DropStatus.java b/src/main/java/com/dropit/drop/entity/DropStatus.java new file mode 100644 index 0000000..cb5b022 --- /dev/null +++ b/src/main/java/com/dropit/drop/entity/DropStatus.java @@ -0,0 +1,8 @@ +package com.dropit.drop.entity; + +public enum DropStatus { + READY, + OPEN, + SOLDOUT, + CLOSED +} diff --git a/src/main/java/com/dropit/drop/exception/DropErrorCode.java b/src/main/java/com/dropit/drop/exception/DropErrorCode.java new file mode 100644 index 0000000..f0f4e45 --- /dev/null +++ b/src/main/java/com/dropit/drop/exception/DropErrorCode.java @@ -0,0 +1,22 @@ +package com.dropit.drop.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum DropErrorCode implements ErrorCode { + + DROP_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 드랍입니다."), + DROP_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "해당 드랍은 판매자만 관리할 수 있습니다."), + DROP_NOT_OPEN(HttpStatus.CONFLICT, "현재 주문할 수 없는 드랍입니다."), + INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "드랍 재고가 부족합니다."), + INVALID_DROP_PERIOD(HttpStatus.BAD_REQUEST, "판매 종료 시각은 시작 시각보다 늦어야 합니다."), + DROP_ALREADY_STARTED(HttpStatus.CONFLICT, "이미 판매가 시작된 드랍입니다."); + + private final HttpStatus status; + private final String message; + + @Override public HttpStatus status() { return status; } + @Override public String message() { return message; } +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepository.java b/src/main/java/com/dropit/drop/repository/DropRepository.java new file mode 100644 index 0000000..828f9d2 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropRepository.java @@ -0,0 +1,21 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.entity.Drop; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DropRepository extends JpaRepository, DropRepositoryCustom { + + Page findAllByVisibleTrue(Pageable pageable); + + Page findAllByProductSellerIdAndVisibleTrue( + Long sellerId, + Pageable pageable + ); + + Page findAllByProductSellerId( + Long sellerId, + Pageable pageable + ); +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java b/src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java new file mode 100644 index 0000000..52744a3 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.entity.Drop; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface DropRepositoryCustom { + + Page searchPublicDrops( + DropSearchCondition condition, + Pageable pageable + ); +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java b/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java new file mode 100644 index 0000000..a410b50 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java @@ -0,0 +1,120 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.dto.request.DropSortType; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.entity.DropStatus; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.dropit.drop.entity.QDrop.drop; +import static com.dropit.product.entity.QProduct.product; +import static com.dropit.user.entity.QUser.user; + +@RequiredArgsConstructor +public class DropRepositoryCustomImpl implements DropRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchPublicDrops( + DropSearchCondition condition, + Pageable pageable + ) { + LocalDateTime now = LocalDateTime.now(); + + List content = queryFactory + .selectFrom(drop) + .join(drop.product, product).fetchJoin() + .join(product.seller, user).fetchJoin() + .where( + drop.visible.isTrue(), + keywordContains(condition.keyword()), + statusEquals(condition.status(), now) + ) + .orderBy(orderSpecifiers(condition.sortType())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(drop.count()) + .from(drop) + .join(drop.product, product) + .join(product.seller, user) + .where( + drop.visible.isTrue(), + keywordContains(condition.keyword()), + statusEquals(condition.status(), now) + ) + .fetchOne(); + + return new PageImpl<>( + content, + pageable, + total != null ? total : 0L + ); + } + + private BooleanExpression keywordContains(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + + return product.name.containsIgnoreCase(keyword) + .or(user.username.containsIgnoreCase(keyword)); + } + + private BooleanExpression statusEquals( + DropStatus status, + LocalDateTime now + ) { + if (status == null) { + return null; + } + + return switch (status) { + case READY -> drop.openAt.gt(now); + case OPEN -> drop.openAt.loe(now) + .and(drop.closeAt.gt(now)) + .and(drop.remainingQuantity.gt(0)); + case SOLDOUT -> drop.openAt.loe(now) + .and(drop.closeAt.gt(now)) + .and(drop.remainingQuantity.eq(0)); + case CLOSED -> drop.closeAt.loe(now); + }; + } + + private OrderSpecifier[] orderSpecifiers(DropSortType sortType) { + if (sortType == null) { + return latestOrder(); + } + + return switch (sortType) { + case LATEST -> latestOrder(); + case PRICE_ASC -> new OrderSpecifier[]{ + drop.price.asc(), + drop.id.desc() + }; + case CLOSING_SOON -> new OrderSpecifier[]{ + drop.closeAt.asc(), + drop.id.desc() + }; + }; + } + + private OrderSpecifier[] latestOrder() { + return new OrderSpecifier[]{ + drop.createdAt.desc(), + drop.id.desc() + }; + } +} diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java new file mode 100644 index 0000000..fc4f494 --- /dev/null +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -0,0 +1,153 @@ +package com.dropit.drop.service; + +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class DropService { + + private final DropRepository dropRepository; + private final ProductRepository productRepository; + + @Transactional + public Long save(Long userId, DropCreateRequest request) { + Product product = productRepository.findById(request.productId()) + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + if (!Objects.equals(product.getSeller().getId(), userId)) { + throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); + } + + Drop drop = new Drop( + product, + request.price(), + request.initialQuantity(), + request.discountRate(), + request.purchaseLimit(), + request.openAt(), + request.closeAt() + ); + + Drop savedDrop = dropRepository.save(drop); + + return savedDrop.getId(); + } + + @Transactional(readOnly = true) + public Page getAll( + DropSearchCondition condition, + Pageable pageable + ) { + Page drops = dropRepository.searchPublicDrops(condition, pageable); + + return drops.map(DropResponse::from); + } + + @Transactional(readOnly = true) + public DropResponse getOne(Long dropId) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!drop.isVisible()) { + throw new ServiceException(DropErrorCode.DROP_NOT_FOUND); + } + + return DropResponse.from(drop); + } + + @Transactional(readOnly = true) + public Page getPublicDropsBySeller( + Long sellerId, + Pageable pageable + ) { + Page drops = dropRepository.findAllByProductSellerIdAndVisibleTrue( + sellerId, + pageable + ); + + return drops.map(DropResponse::from); + } + + @Transactional(readOnly = true) + public Page getDropsForSellerManagement( + Long sellerId, + Pageable pageable + ) { + Page drops = dropRepository.findAllByProductSellerId( + sellerId, + pageable + ); + + return drops.map(DropResponse::from); + } + + @Transactional + public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + + drop.ensureEditable(LocalDateTime.now()); + + drop.update( + request.price(), + request.initialQuantity(), + request.discountRate(), + request.purchaseLimit(), + request.openAt(), + request.closeAt() + ); + + return DropResponse.from(drop); + } + + @Transactional + public DropResponse changeVisibility(Long sellerId, Long dropId, DropVisibilityUpdateRequest request) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + + drop.changeVisibility(request.visible()); + + return DropResponse.from(drop); + } + + @Transactional + public void delete(Long sellerId, Long dropId) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + + drop.ensureEditable(LocalDateTime.now()); + + dropRepository.delete(drop); + } +} diff --git a/src/main/java/com/dropit/global/config/QuerydslConfig.java b/src/main/java/com/dropit/global/config/QuerydslConfig.java new file mode 100644 index 0000000..660d3a3 --- /dev/null +++ b/src/main/java/com/dropit/global/config/QuerydslConfig.java @@ -0,0 +1,15 @@ +package com.dropit.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/dropit/global/entity/BaseEntity.java b/src/main/java/com/dropit/global/entity/BaseEntity.java new file mode 100644 index 0000000..52ff7d9 --- /dev/null +++ b/src/main/java/com/dropit/global/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package com.dropit.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java b/src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java new file mode 100644 index 0000000..38d2697 --- /dev/null +++ b/src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java @@ -0,0 +1,23 @@ +package com.dropit.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +public abstract class BaseSoftDeletableEntity extends BaseEntity { + + @Column + private LocalDateTime deletedAt; + + public void markDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return deletedAt != null; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/exception/CommonErrorCode.java b/src/main/java/com/dropit/global/exception/CommonErrorCode.java new file mode 100644 index 0000000..dfa5739 --- /dev/null +++ b/src/main/java/com/dropit/global/exception/CommonErrorCode.java @@ -0,0 +1,18 @@ +package com.dropit.global.exception; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + + INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력 값이 올바르지 않습니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String message; + + @Override public HttpStatus status() { return status; } + @Override public String message() { return message; } +} diff --git a/src/main/java/com/dropit/global/exception/ErrorCode.java b/src/main/java/com/dropit/global/exception/ErrorCode.java new file mode 100644 index 0000000..6954613 --- /dev/null +++ b/src/main/java/com/dropit/global/exception/ErrorCode.java @@ -0,0 +1,14 @@ +package com.dropit.global.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + HttpStatus status(); + + String message(); + + default String code() { + return ((Enum) this).name(); + } +} diff --git a/src/main/java/com/dropit/global/exception/ErrorResponse.java b/src/main/java/com/dropit/global/exception/ErrorResponse.java new file mode 100644 index 0000000..a3ea434 --- /dev/null +++ b/src/main/java/com/dropit/global/exception/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.dropit.global.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Map; + +public record ErrorResponse( + String code, + String message, + @JsonInclude(JsonInclude.Include.NON_EMPTY) Map errors +) { + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse(errorCode.code(), errorCode.message(), null); + } + + public static ErrorResponse from(ErrorCode errorCode, Map errors) { + return new ErrorResponse(errorCode.code(), errorCode.message(), errors); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java b/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1b48e0d --- /dev/null +++ b/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package com.dropit.global.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ServiceException.class) + public ResponseEntity handleServiceException(ServiceException e) { + ErrorCode errorCode = e.getErrorCode(); + + return ResponseEntity + .status(errorCode.status()) + .body(ErrorResponse.from(errorCode)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + // Bean Validation 실패 시 클라이언트가 수정할 필드를 알 수 있도록 필드별 메시지를 반환합니다. + Map errors = new LinkedHashMap<>(); + e.getBindingResult().getFieldErrors().forEach(error -> + errors.putIfAbsent(error.getField(), error.getDefaultMessage())); + + return ResponseEntity + .badRequest() + .body(ErrorResponse.from(CommonErrorCode.INVALID_INPUT, errors)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException() { + return ResponseEntity + .badRequest() + .body(ErrorResponse.from(CommonErrorCode.INVALID_REQUEST)); + } +} diff --git a/src/main/java/com/dropit/global/exception/ServiceException.java b/src/main/java/com/dropit/global/exception/ServiceException.java new file mode 100644 index 0000000..3974e1e --- /dev/null +++ b/src/main/java/com/dropit/global/exception/ServiceException.java @@ -0,0 +1,14 @@ +package com.dropit.global.exception; + +import lombok.Getter; + +@Getter +public class ServiceException extends RuntimeException { + + private final ErrorCode errorCode; + + public ServiceException(ErrorCode errorCode) { + super(errorCode.message()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/security/SecurityConfig.java b/src/main/java/com/dropit/global/security/SecurityConfig.java new file mode 100644 index 0000000..d95a3d0 --- /dev/null +++ b/src/main/java/com/dropit/global/security/SecurityConfig.java @@ -0,0 +1,70 @@ +package com.dropit.global.security; + +import com.dropit.global.security.jwt.JwtErrorCode; +import com.dropit.global.security.jwt.JwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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.SecurityFilterChain; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtFilter jwtFilter; + private final SecurityErrorResponseSender errorResponseSender; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) // 쿠키가 아닌 Authorization 헤더로 JWT를 전달하므로 CSRF 보호를 사용하지 않음 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .requestCache(AbstractHttpConfigurer::disable) // 인증 전 요청을 세션에 저장한 뒤 복원하는 리다이렉트 흐름을 사용하지 않음 + .formLogin(AbstractHttpConfigurer::disable) // 서버가 로그인 HTML 폼을 제공하는 방식을 사용하지 않음 + .httpBasic(AbstractHttpConfigurer::disable) // 브라우저의 HTTP Basic 인증 팝업 방식을 사용하지 않음 + .logout(AbstractHttpConfigurer::disable) // 서버 세션을 무효화하는 로그아웃 방식이 아닌 JWT 폐기 방식을 사용함 + .rememberMe(AbstractHttpConfigurer::disable) // 자동 로그인을 위한 Remember-Me 쿠키를 발급하지 않음 + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, cause) -> + errorResponseSender.send(response, JwtErrorCode.JWT_BEARER_TOKEN_REQUIRED)) + .accessDeniedHandler((request, response, cause) -> + errorResponseSender.send(response, JwtErrorCode.JWT_ACCESS_DENIED)) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll() + .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/auth/reissue").permitAll() + .anyRequest().authenticated() + ) + // JWT 인증이 익명 인증보다 먼저 처리되도록 순서 지정 + .addFilterBefore(jwtFilter, AnonymousAuthenticationFilter.class) + .build(); + } + + // JwtFilter가 Spring Security 체인에서만 실행되도록 자동 등록을 막는다. (서블릿 컨테이너 중복 등록 방지) + @Bean + public FilterRegistrationBean jwtFilterRegistration(JwtFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } +} + diff --git a/src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java b/src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java new file mode 100644 index 0000000..04cd804 --- /dev/null +++ b/src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java @@ -0,0 +1,31 @@ +package com.dropit.global.security; + +import com.dropit.global.exception.ErrorCode; +import com.dropit.global.exception.ErrorResponse; +import com.dropit.global.exception.ServiceException; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class SecurityErrorResponseSender { + + private final ObjectMapper objectMapper; + + public void send(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.status().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.from(errorCode))); + } + + public void send(HttpServletResponse response, ServiceException exception) throws IOException { + send(response, exception.getErrorCode()); + } +} diff --git a/src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java b/src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java new file mode 100644 index 0000000..9264afc --- /dev/null +++ b/src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java @@ -0,0 +1,47 @@ +package com.dropit.global.security.authentication; + +import com.dropit.global.security.principal.AuthUser; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public final class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final AuthUser principal; + + public JwtAuthenticationToken( + AuthUser principal, + Collection authorities + ) { + super(authorities); + this.principal = principal; + super.setAuthenticated(true); + } + + @Override + public AuthUser getPrincipal() { + return principal; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public String getName() { + return principal.getName(); + } + + @Override + public void setAuthenticated(boolean authenticated) { + if (authenticated) { + throw new IllegalArgumentException( + "인증 완료 상태는 JwtAuthenticationToken 생성자로만 설정할 수 있습니다." + ); + } + super.setAuthenticated(false); + } +} + diff --git a/src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java b/src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java new file mode 100644 index 0000000..b68adb2 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java @@ -0,0 +1,37 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ErrorCode; +import com.dropit.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum JwtErrorCode implements ErrorCode { + + JWT_BEARER_TOKEN_REQUIRED(HttpStatus.UNAUTHORIZED, "Authorization 헤더에 Bearer 토큰이 필요합니다"), + JWT_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다"), + JWT_TOKEN_SIGNATURE_ERROR(HttpStatus.UNAUTHORIZED, "토큰 서명이 일치하지 않습니다"), + JWT_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 올바르지 않습니다"), + JWT_TOKEN_UNSUPPORTED_ERROR(HttpStatus.UNAUTHORIZED, "지원하지 않는 토큰입니다"), + JWT_UNKNOWN_EXCEPTION(HttpStatus.UNAUTHORIZED, "JWT 처리 중 알 수 없는 오류가 발생했습니다"), + JWT_INVALID_TOKEN_TYPE(HttpStatus.UNAUTHORIZED, "요청에 맞는 토큰 타입이 아닙니다"), + JWT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } + + public ServiceException toException() { + return new ServiceException(this); + } +} + diff --git a/src/main/java/com/dropit/global/security/jwt/JwtFilter.java b/src/main/java/com/dropit/global/security/jwt/JwtFilter.java new file mode 100644 index 0000000..c0a0296 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtFilter.java @@ -0,0 +1,89 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.SecurityErrorResponseSender; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.user.entity.UserRole; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final SecurityErrorResponseSender errorResponseSender; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader("Authorization"); + + // Bearer 토큰이 없는 요청의 허용 여부는 SecurityConfig가 판단한다. + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.substring("Bearer ".length()); + + try { + authenticate(token, request); + } catch (ServiceException exception) { + errorResponseSender.send(response, exception); + return; + } + + filterChain.doFilter(request, response); + } + + private void authenticate(String token, HttpServletRequest request) { + Claims claims = jwtUtil.getAccessTokenClaims(token); + Long userId = parseUserId(claims.getSubject()); + UserRole role = parseRole(claims.get(JwtUtil.USER_ROLE_CLAIM, String.class)); + + JwtAuthenticationToken authentication = new JwtAuthenticationToken( + new AuthUser(userId), // principal + List.of(new SimpleGrantedAuthority(role.authority())) // authorities + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + } + + private Long parseUserId(String subject) { + try { + return Long.valueOf(subject); + } catch (NumberFormatException exception) { + throw JwtErrorCode.JWT_TOKEN_ERROR.toException(); + } + } + + private UserRole parseRole(String roleClaim) { + try { + return UserRole.valueOf(roleClaim); + } catch (IllegalArgumentException | NullPointerException exception) { + throw JwtErrorCode.JWT_TOKEN_ERROR.toException(); + } + } +} + diff --git a/src/main/java/com/dropit/global/security/jwt/JwtProperties.java b/src/main/java/com/dropit/global/security/jwt/JwtProperties.java new file mode 100644 index 0000000..7a25a8d --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtProperties.java @@ -0,0 +1,17 @@ +package com.dropit.global.security.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String secret, + Expire expire +) { + public record Expire( + Duration access, + Duration refresh + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java b/src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java new file mode 100644 index 0000000..368c4b7 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java @@ -0,0 +1,7 @@ +package com.dropit.global.security.jwt; + +public record JwtTokenPair( + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/security/jwt/JwtUtil.java b/src/main/java/com/dropit/global/security/jwt/JwtUtil.java new file mode 100644 index 0000000..0f3d109 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtUtil.java @@ -0,0 +1,98 @@ +package com.dropit.global.security.jwt; + +import com.dropit.user.entity.User; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.util.Date; +import java.util.UUID; + +@Component +public class JwtUtil { + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + public JwtUtil(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public static final String TOKEN_TYPE_ACCESS = "access"; + public static final String TOKEN_TYPE_REFRESH = "refresh"; + + public static final String TOKEN_TYPE_CLAIM = "type"; + public static final String USER_ROLE_CLAIM = "role"; + + public JwtTokenPair issueTokenPair(User user) { + String accessToken = createToken(user, TOKEN_TYPE_ACCESS, jwtProperties.expire().access(), true); + String refreshToken = createToken(user, TOKEN_TYPE_REFRESH, jwtProperties.expire().refresh(), false); + + return new JwtTokenPair(accessToken, refreshToken); + } + + private String createToken(User user, String tokenType, Duration tokenExpired, boolean includeRole) { + long now = System.currentTimeMillis(); + + JwtBuilder builder = Jwts.builder() + .subject(user.getId().toString()) // sub + .id(UUID.randomUUID().toString()) // jti + .claim(TOKEN_TYPE_CLAIM, tokenType) + .issuedAt(new Date(now)) // iat + .expiration(new Date(now + tokenExpired.toMillis())); // exp + + if (includeRole) { + builder.claim(USER_ROLE_CLAIM, user.getRole().name()); + } + + return builder + .signWith(secretKey) + .compact(); + } + + public Claims getAccessTokenClaims(String token) { + return getTokenClaims(token, TOKEN_TYPE_ACCESS); + } + + public Claims getRefreshTokenClaims(String token) { + return getTokenClaims(token, TOKEN_TYPE_REFRESH); + } + + private Claims getTokenClaims(String token, String expectedTokenType) { + Claims claims = getClaims(token); + String actualTokenType = claims.get(TOKEN_TYPE_CLAIM, String.class); + + if (!expectedTokenType.equals(actualTokenType)) { + throw JwtErrorCode.JWT_INVALID_TOKEN_TYPE.toException(); + } + + return claims; + } + + private Claims getClaims(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException exception) { + throw JwtErrorCode.JWT_TOKEN_EXPIRED.toException(); + } catch (SignatureException exception) { + throw JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR.toException(); + } catch (MalformedJwtException | IllegalArgumentException exception) { + throw JwtErrorCode.JWT_TOKEN_ERROR.toException(); + } catch (UnsupportedJwtException exception) { + throw JwtErrorCode.JWT_TOKEN_UNSUPPORTED_ERROR.toException(); + } catch (JwtException exception) { + throw JwtErrorCode.JWT_UNKNOWN_EXCEPTION.toException(); + } + } +} diff --git a/src/main/java/com/dropit/global/security/principal/AuthUser.java b/src/main/java/com/dropit/global/security/principal/AuthUser.java new file mode 100644 index 0000000..fda34b2 --- /dev/null +++ b/src/main/java/com/dropit/global/security/principal/AuthUser.java @@ -0,0 +1,10 @@ +package com.dropit.global.security.principal; + +import org.springframework.security.core.AuthenticatedPrincipal; + +public record AuthUser(Long id) implements AuthenticatedPrincipal { + @Override + public String getName() { + return id.toString(); + } +} diff --git a/src/main/java/com/dropit/global/security/principal/CurrentUser.java b/src/main/java/com/dropit/global/security/principal/CurrentUser.java new file mode 100644 index 0000000..d23bed6 --- /dev/null +++ b/src/main/java/com/dropit/global/security/principal/CurrentUser.java @@ -0,0 +1,12 @@ +package com.dropit.global.security.principal; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal(errorOnInvalidType = true) +public @interface CurrentUser { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/security/principal/CurrentUserId.java b/src/main/java/com/dropit/global/security/principal/CurrentUserId.java new file mode 100644 index 0000000..634a09c --- /dev/null +++ b/src/main/java/com/dropit/global/security/principal/CurrentUserId.java @@ -0,0 +1,15 @@ +package com.dropit.global.security.principal; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal( + expression = "id", + errorOnInvalidType = true +) +public @interface CurrentUserId { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/order/controller/OrderController.java b/src/main/java/com/dropit/order/controller/OrderController.java new file mode 100644 index 0000000..170cba6 --- /dev/null +++ b/src/main/java/com/dropit/order/controller/OrderController.java @@ -0,0 +1,58 @@ +package com.dropit.order.controller; + +import com.dropit.global.security.principal.CurrentUserId; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.service.OrderService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + + @PostMapping("/orders") + public ResponseEntity create( + @CurrentUserId Long userId, + @Valid @RequestBody OrderCreateRequest request + ) { + OrderResponse response = orderService.create(userId, request); + + return ResponseEntity + .created(URI.create("/orders/" + response.id())) + .body(response); + } + + @GetMapping("/orders/me") + public ResponseEntity> getMyOrders( + @CurrentUserId Long userId + ) { + return ResponseEntity.ok(orderService.getMyOrders(userId)); + } + + @GetMapping("/orders/{orderId}") + public ResponseEntity getMyOrder( + @CurrentUserId Long userId, + @PathVariable Long orderId + ) { + return ResponseEntity.ok(orderService.getMyOrder(userId, orderId)); + } + + @PostMapping("/orders/{orderId}/cancel") + public ResponseEntity cancel( + @CurrentUserId Long userId, + @PathVariable Long orderId + ) { + orderService.cancel(userId, orderId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java b/src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java new file mode 100644 index 0000000..0e9f761 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java @@ -0,0 +1,14 @@ +package com.dropit.order.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record OrderCreateRequest( + @NotNull(message = "주문 항목은 필수입니다.") + @Size(min = 1, max = 1, message = "현재 주문 항목은 1개만 입력할 수 있습니다.") + List<@Valid OrderItemCreateRequest> items +) { +} diff --git a/src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java b/src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java new file mode 100644 index 0000000..bbdcbb2 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java @@ -0,0 +1,13 @@ +package com.dropit.order.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record OrderItemCreateRequest( + @NotNull(message = "드랍 ID는 필수입니다.") + Long dropId, + + @Positive(message = "주문 수량은 1개 이상이어야 합니다.") + int quantity +) { +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderItemResponse.java b/src/main/java/com/dropit/order/dto/response/OrderItemResponse.java new file mode 100644 index 0000000..b5137c5 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderItemResponse.java @@ -0,0 +1,27 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.OrderItem; + +import java.math.BigDecimal; + +public record OrderItemResponse( + Long id, + Long dropId, + String productName, + BigDecimal unitPrice, + int discountRate, + int quantity, + BigDecimal itemTotalPrice +) { + public static OrderItemResponse from(OrderItem orderItem) { + return new OrderItemResponse( + orderItem.getId(), + orderItem.getDrop().getId(), + orderItem.getProductName(), + orderItem.getUnitPrice(), + orderItem.getDiscountRate(), + orderItem.getQuantity(), + orderItem.getItemTotalPrice() + ); + } +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderResponse.java b/src/main/java/com/dropit/order/dto/response/OrderResponse.java new file mode 100644 index 0000000..4b9c0cd --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderResponse.java @@ -0,0 +1,27 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record OrderResponse( + Long id, + OrderStatus status, + BigDecimal totalPrice, + LocalDateTime createdAt, + List items +) { + public static OrderResponse from(Order order, List orderItems) { + return new OrderResponse( + order.getId(), + order.getStatus(), + order.getTotalPrice(), + order.getCreatedAt(), + orderItems.stream().map(OrderItemResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java b/src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java new file mode 100644 index 0000000..a6caafe --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java @@ -0,0 +1,23 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record OrderSummaryResponse( + Long id, + OrderStatus status, + BigDecimal totalPrice, + LocalDateTime createdAt +) { + public static OrderSummaryResponse from(Order order) { + return new OrderSummaryResponse( + order.getId(), + order.getStatus(), + order.getTotalPrice(), + order.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/dropit/order/entity/Order.java b/src/main/java/com/dropit/order/entity/Order.java new file mode 100644 index 0000000..5a82926 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/Order.java @@ -0,0 +1,49 @@ +package com.dropit.order.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@Entity +@Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 모든 주문 항목의 금액을 합한 전체 결제 금액 + @Column(name = "total_price", nullable = false, precision = 13, scale = 0) + private BigDecimal totalPrice; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + public Order(User user, BigDecimal totalPrice) { + this.user = user; + this.totalPrice = totalPrice; + this.status = OrderStatus.ORDERED; + } + + public void cancel() { + if (status == OrderStatus.CANCELED) { + throw new ServiceException(OrderErrorCode.ORDER_ALREADY_CANCELED); + } + + this.status = OrderStatus.CANCELED; + } +} diff --git a/src/main/java/com/dropit/order/entity/OrderItem.java b/src/main/java/com/dropit/order/entity/OrderItem.java new file mode 100644 index 0000000..66858df --- /dev/null +++ b/src/main/java/com/dropit/order/entity/OrderItem.java @@ -0,0 +1,79 @@ +package com.dropit.order.entity; + +import com.dropit.drop.entity.Drop; +import com.dropit.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Getter +@Entity +@Table(name = "order_items") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItem extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + // 어떤 드랍 판매에서 구매했는지 추적하기 위한 원본 참조 + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "drop_id", nullable = false) + private Drop drop; + + // 주문 당시 상품명 스냅샷 + @Column(name = "product_name", nullable = false, length = 100) + private String productName; + + // 주문 당시 정가 스냅샷 + @Column(name = "unit_price", nullable = false, precision = 13, scale = 0) + private BigDecimal unitPrice; + + // 주문 당시 적용된 할인율 스냅샷 + @Column(name = "discount_rate", nullable = false) + private int discountRate; + + // 같은 상품+옵션 조합으로 구매한 수량 + @Column(nullable = false) + private int quantity; + + // 이 주문 항목의 최종 결제 금액: unitPrice × (1 - discountRate × 0.01) × quantity + @Column(name = "item_total_price", nullable = false, precision = 13, scale = 0) + private BigDecimal itemTotalPrice; + + public OrderItem( + Order order, + Drop drop, + String productName, + BigDecimal unitPrice, + int discountRate, + int quantity + ) { + this.order = order; + this.drop = drop; + this.productName = productName; + this.unitPrice = unitPrice; + this.discountRate = discountRate; + this.quantity = quantity; + this.itemTotalPrice = calculateItemTotalPrice(unitPrice, discountRate, quantity); + } + + public static BigDecimal calculateItemTotalPrice( + BigDecimal unitPrice, + int discountRate, + int quantity + ) { + return unitPrice + .multiply(BigDecimal.ONE.subtract(BigDecimal.valueOf(discountRate).movePointLeft(2))) + .multiply(BigDecimal.valueOf(quantity)) + .setScale(0, RoundingMode.DOWN); + } +} diff --git a/src/main/java/com/dropit/order/entity/OrderStatus.java b/src/main/java/com/dropit/order/entity/OrderStatus.java new file mode 100644 index 0000000..1e3b254 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/OrderStatus.java @@ -0,0 +1,6 @@ +package com.dropit.order.entity; + +public enum OrderStatus { + ORDERED, + CANCELED +} diff --git a/src/main/java/com/dropit/order/exception/OrderErrorCode.java b/src/main/java/com/dropit/order/exception/OrderErrorCode.java new file mode 100644 index 0000000..db8aad9 --- /dev/null +++ b/src/main/java/com/dropit/order/exception/OrderErrorCode.java @@ -0,0 +1,19 @@ +package com.dropit.order.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주문입니다."), + ORDER_ALREADY_CANCELED(HttpStatus.CONFLICT, "이미 취소된 주문입니다."), + PURCHASE_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "드랍의 1인당 구매 제한 수량을 초과했습니다."); + + private final HttpStatus status; + private final String message; + + @Override public HttpStatus status() { return status; } + @Override public String message() { return message; } +} diff --git a/src/main/java/com/dropit/order/repository/OrderItemRepository.java b/src/main/java/com/dropit/order/repository/OrderItemRepository.java new file mode 100644 index 0000000..19b7442 --- /dev/null +++ b/src/main/java/com/dropit/order/repository/OrderItemRepository.java @@ -0,0 +1,27 @@ +package com.dropit.order.repository; + +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface OrderItemRepository extends JpaRepository { + + List findAllByOrder_IdOrderByIdAsc(Long orderId); + + @Query(""" + select coalesce(sum(oi.quantity), 0) + from OrderItem oi + where oi.order.user.id = :userId + and oi.drop.id = :dropId + and oi.order.status = :status + """) + long sumQuantityByUserAndDropAndStatus( + @Param("userId") Long userId, + @Param("dropId") Long dropId, + @Param("status") OrderStatus status + ); +} diff --git a/src/main/java/com/dropit/order/repository/OrderRepository.java b/src/main/java/com/dropit/order/repository/OrderRepository.java new file mode 100644 index 0000000..52b3dfd --- /dev/null +++ b/src/main/java/com/dropit/order/repository/OrderRepository.java @@ -0,0 +1,14 @@ +package com.dropit.order.repository; + +import com.dropit.order.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository extends JpaRepository { + + List findAllByUser_IdOrderByCreatedAtDesc(Long userId); + + Optional findByIdAndUser_Id(Long orderId, Long userId); +} diff --git a/src/main/java/com/dropit/order/service/OrderService.java b/src/main/java/com/dropit/order/service/OrderService.java new file mode 100644 index 0000000..9e17ef2 --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderService.java @@ -0,0 +1,114 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.request.OrderItemCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.user.entity.User; +import com.dropit.user.exception.UserErrorCode; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final UserRepository userRepository; + private final DropRepository dropRepository; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderResponse create(Long userId, OrderCreateRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ServiceException(UserErrorCode.USER_NOT_FOUND)); + + OrderItemCreateRequest itemRequest = request.items().getFirst(); + Drop drop = dropRepository.findById(itemRequest.dropId()) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + LocalDateTime now = LocalDateTime.now(); + validatePurchaseLimit(userId, drop, itemRequest.quantity()); + drop.decreaseStock(itemRequest.quantity(), now); + BigDecimal totalPrice = OrderItem.calculateItemTotalPrice( + drop.getPrice(), + drop.getDiscountRate(), + itemRequest.quantity() + ); + + Order order = orderRepository.save(new Order(user, totalPrice)); + List orderItems = List.of(createOrderItem(order, drop, itemRequest.quantity())); + List savedOrderItems = orderItemRepository.saveAll(orderItems); + + return OrderResponse.from(order, savedOrderItems); + } + + @Transactional(readOnly = true) + public List getMyOrders(Long userId) { + return orderRepository.findAllByUser_IdOrderByCreatedAtDesc(userId).stream() + .map(OrderSummaryResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderResponse getMyOrder(Long userId, Long orderId) { + Order order = orderRepository.findByIdAndUser_Id(orderId, userId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_NOT_FOUND)); + List orderItems = orderItemRepository.findAllByOrder_IdOrderByIdAsc(orderId); + + return OrderResponse.from(order, orderItems); + } + + @Transactional + public void cancel(Long userId, Long orderId) { + Order order = orderRepository.findByIdAndUser_Id(orderId, userId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_NOT_FOUND)); + List orderItems = orderItemRepository.findAllByOrder_IdOrderByIdAsc(orderId); + + order.cancel(); + for (OrderItem orderItem : orderItems) { + orderItem.getDrop().restoreStock(orderItem.getQuantity()); + } + } + + private void validatePurchaseLimit(Long userId, Drop drop, int quantity) { + if (drop.getPurchaseLimit() == 0) { + return; + } + + long purchasedQuantity = orderItemRepository.sumQuantityByUserAndDropAndStatus( + userId, + drop.getId(), + OrderStatus.ORDERED + ); + if (purchasedQuantity + quantity > drop.getPurchaseLimit()) { + throw new ServiceException(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED); + } + } + + private OrderItem createOrderItem(Order order, Drop drop, int quantity) { + return new OrderItem( + order, + drop, + drop.getProduct().getName(), + drop.getPrice(), + drop.getDiscountRate(), + quantity + ); + } +} diff --git a/src/main/java/com/dropit/product/controller/ProductController.java b/src/main/java/com/dropit/product/controller/ProductController.java new file mode 100644 index 0000000..30cad30 --- /dev/null +++ b/src/main/java/com/dropit/product/controller/ProductController.java @@ -0,0 +1,90 @@ +package com.dropit.product.controller; + +import com.dropit.global.security.principal.CurrentUserId; +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.service.ProductService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +@RestController +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @PostMapping("/products") + public ResponseEntity create( + @CurrentUserId Long sellerId, + @Valid @RequestBody ProductCreateRequest request + ) { + Long productId = productService.create(sellerId, request); + + return ResponseEntity + .created(URI.create("/products/" + productId)) + .body(productId); + } + + @GetMapping("/products/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductResponse response = productService.getProduct(productId); + + return ResponseEntity.ok(response); + } + + @GetMapping("/products") + public ResponseEntity> getProducts( + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page response = productService.getProducts(pageable); + + return ResponseEntity.ok(response); + } + + @GetMapping("/sellers/{sellerId}/products") + public ResponseEntity> getProductsBySeller( + @PathVariable Long sellerId, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page response = productService.getProductsBySeller(sellerId, pageable); + + return ResponseEntity.ok(response); + } + + @PutMapping("/products/{productId}") + public ResponseEntity update( + @CurrentUserId Long sellerId, + @PathVariable Long productId, + @Valid @RequestBody ProductUpdateRequest request + ) { + ProductResponse response = productService.update(sellerId, productId, request); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/products/{productId}") + public ResponseEntity delete( + @CurrentUserId Long sellerId, + @PathVariable Long productId + ) { + productService.delete(sellerId, productId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java b/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java new file mode 100644 index 0000000..ef9df66 --- /dev/null +++ b/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java @@ -0,0 +1,22 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ProductCreateRequest { + + @NotBlank(message = "상품명은 필수입니다.") + @Size(max = 100, message = "상품명은 100자 이하여야 합니다.") + private String name; + + private String description; + + public ProductCreateRequest(String name, String description) { + this.name = name; + this.description = description; + } +} diff --git a/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java b/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java new file mode 100644 index 0000000..6d4f0c3 --- /dev/null +++ b/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java @@ -0,0 +1,23 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ProductUpdateRequest { + + @NotBlank(message = "상품명은 필수입니다.") + @Size(max = 100, message = "상품명은 100자 이하여야 합니다.") + private String name; + + @Size(max = 3000, message = "상품 설명은 3000자 이하여야 합니다.") + private String description; + + public ProductUpdateRequest(String name, String description) { + this.name = name; + this.description = description; + } +} diff --git a/src/main/java/com/dropit/product/dto/response/ProductResponse.java b/src/main/java/com/dropit/product/dto/response/ProductResponse.java new file mode 100644 index 0000000..e949706 --- /dev/null +++ b/src/main/java/com/dropit/product/dto/response/ProductResponse.java @@ -0,0 +1,34 @@ +package com.dropit.product.dto.response; + +import com.dropit.product.entity.Product; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ProductResponse { + + private final Long id; + private final Long sellerId; + private final String sellerName; + + private final String name; + private final String description; + private final String imageUrl; + + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public ProductResponse(Product product) { + this.id = product.getId(); + this.sellerId = product.getSeller().getId(); + this.sellerName = product.getSeller().getUsername(); + + this.name = product.getName(); + this.description = product.getDescription(); + this.imageUrl = product.getImageUrl(); + + this.createdAt = product.getCreatedAt(); + this.updatedAt = product.getUpdatedAt(); + } +} diff --git a/src/main/java/com/dropit/product/entity/Product.java b/src/main/java/com/dropit/product/entity/Product.java new file mode 100644 index 0000000..3d3312f --- /dev/null +++ b/src/main/java/com/dropit/product/entity/Product.java @@ -0,0 +1,56 @@ +package com.dropit.product.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "products") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "seller_id", nullable = false) + private User seller; + + @Column(nullable = false, length = 100) + private String name; + + @Column(columnDefinition = "text") + private String description; + + @Column(name = "image_url", length = 2048) + private String imageUrl; + + public Product(User seller, String name, String description, String imageUrl) { + this.seller = seller; + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + } + + public void updateInfo(String name, String description) { + this.name = name; + this.description = description; + } + + public void changeImage(String imageUrl) { + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/dropit/product/exception/ProductErrorCode.java b/src/main/java/com/dropit/product/exception/ProductErrorCode.java new file mode 100644 index 0000000..ca63a44 --- /dev/null +++ b/src/main/java/com/dropit/product/exception/ProductErrorCode.java @@ -0,0 +1,27 @@ +package com.dropit.product.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum ProductErrorCode implements ErrorCode { + + SELLER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 판매자입니다."), + SELLER_ROLE_REQUIRED(HttpStatus.FORBIDDEN, "판매자만 상품을 등록할 수 있습니다."), + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 상품입니다."), + PRODUCT_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "상품 소유자만 변경할 수 있습니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/dropit/product/repository/ProductRepository.java b/src/main/java/com/dropit/product/repository/ProductRepository.java new file mode 100644 index 0000000..5b13567 --- /dev/null +++ b/src/main/java/com/dropit/product/repository/ProductRepository.java @@ -0,0 +1,11 @@ +package com.dropit.product.repository; + +import com.dropit.product.entity.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { + + Page findAllBySellerId(Long sellerId, Pageable pageable); +} diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java new file mode 100644 index 0000000..659ee8b --- /dev/null +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -0,0 +1,108 @@ +package com.dropit.product.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final UserRepository userRepository; + + @Transactional + public Long create(Long sellerId, ProductCreateRequest request) { + User seller = userRepository.findById(sellerId) + .orElseThrow(() -> new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); + + if (seller.getRole() != UserRole.SELLER) { + throw new ServiceException(ProductErrorCode.SELLER_ROLE_REQUIRED); + } + + Product product = new Product( + seller, + request.getName(), + request.getDescription(), + null + ); + + Product savedProduct = productRepository.save(product); + + return savedProduct.getId(); + } + + @Transactional(readOnly = true) + public ProductResponse getProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + return new ProductResponse(product); + } + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable) { + Page products = productRepository.findAll(pageable); + + return products.map(product -> new ProductResponse(product)); + } + + @Transactional(readOnly = true) + public Page getProductsBySeller(Long sellerId, Pageable pageable) { + User seller = userRepository.findById(sellerId) + .orElseThrow(() -> new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); + + if (seller.getRole() != UserRole.SELLER) { + throw new ServiceException(ProductErrorCode.SELLER_NOT_FOUND); + } + + Page products = productRepository.findAllBySellerId(sellerId, pageable); + + return products.map(product -> new ProductResponse(product)); + } + + @Transactional + public ProductResponse update( + Long sellerId, + Long productId, + ProductUpdateRequest request + ) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + if (!product.getSeller().getId().equals(sellerId)) { + throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); + } + + product.updateInfo( + request.getName(), + request.getDescription() + ); + + return new ProductResponse(product); + } + + @Transactional + public void delete(Long sellerId, Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + if (!product.getSeller().getId().equals(sellerId)) { + throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); + } + + productRepository.delete(product); + } +} diff --git a/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java new file mode 100644 index 0000000..e75c088 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java @@ -0,0 +1,68 @@ +package com.dropit.sellerprofile.controller; + +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.service.SellerProfileService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.dropit.global.security.principal.CurrentUserId; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/seller-profiles") +public class SellerProfileController { + + private final SellerProfileService sellerProfileService; + + @PostMapping + public ResponseEntity create( + @CurrentUserId Long userId, + @Valid @RequestBody SellerProfileCreateRequest request + ) { + SellerProfileResponse response = sellerProfileService.create(userId, request); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/me") + public ResponseEntity getMine( + @CurrentUserId Long userId + ) { + return ResponseEntity.ok(sellerProfileService.getMine(userId)); + } + + @GetMapping("/{sellerProfileId}") + public ResponseEntity getById( + @PathVariable Long sellerProfileId + ) { + return ResponseEntity.ok(sellerProfileService.getById(sellerProfileId)); + } + + @PatchMapping("/me") + public ResponseEntity update( + @CurrentUserId Long userId, + @Valid @RequestBody SellerProfileUpdateRequest request + ) { + return ResponseEntity.ok(sellerProfileService.update(userId, request)); + } + + @DeleteMapping("/me") + public ResponseEntity delete( + @CurrentUserId Long userId + ) { + sellerProfileService.delete(userId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java new file mode 100644 index 0000000..007a532 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java @@ -0,0 +1,18 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.constraints.Size; + +public record SellerProfileCreateRequest( + @Size(max = 2000, message = "판매자 소개는 2000자 이하여야 합니다.") + String description, + + @Size(max = 2048, message = "이미지 URL은 2048자 이하여야 합니다.") + String imageUrl, + + @Size(max = 2048, message = "인스타그램 URL은 2048자 이하여야 합니다.") + String instagramUrl, + + @Size(max = 2048, message = "유튜브 URL은 2048자 이하여야 합니다.") + String youtubeUrl +) { +} diff --git a/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java new file mode 100644 index 0000000..ff3feb5 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java @@ -0,0 +1,18 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.constraints.Size; + +public record SellerProfileUpdateRequest( + @Size(max = 2000, message = "판매자 소개는 2000자 이하여야 합니다.") + String description, + + @Size(max = 2048, message = "이미지 URL은 2048자 이하여야 합니다.") + String imageUrl, + + @Size(max = 2048, message = "인스타그램 URL은 2048자 이하여야 합니다.") + String instagramUrl, + + @Size(max = 2048, message = "유튜브 URL은 2048자 이하여야 합니다.") + String youtubeUrl +) { +} diff --git a/src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java b/src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java new file mode 100644 index 0000000..23f68ff --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java @@ -0,0 +1,24 @@ +package com.dropit.sellerprofile.dto.response; + +import com.dropit.sellerprofile.entity.SellerProfile; + +public record SellerProfileResponse( + Long id, + Long userId, + String description, + String imageUrl, + String instagramUrl, + String youtubeUrl +) { + + public static SellerProfileResponse from(SellerProfile sellerProfile) { + return new SellerProfileResponse( + sellerProfile.getId(), + sellerProfile.getUser().getId(), + sellerProfile.getDescription(), + sellerProfile.getImageUrl(), + sellerProfile.getInstagramUrl(), + sellerProfile.getYoutubeUrl() + ); + } +} diff --git a/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java b/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java new file mode 100644 index 0000000..ba0da3c --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java @@ -0,0 +1,61 @@ +package com.dropit.sellerprofile.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "seller_profiles") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SellerProfile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(columnDefinition = "text") + private String description; + + @Column(name = "image_url", length = 2048) + private String imageUrl; + + @Column(name = "instagram_url", length = 2048) + private String instagramUrl; + + @Column(name = "youtube_url", length = 2048) + private String youtubeUrl; + + public SellerProfile( + User user, + String description, + String imageUrl, + String instagramUrl, + String youtubeUrl + ) { + this.user = user; + this.description = description; + this.imageUrl = imageUrl; + this.instagramUrl = instagramUrl; + this.youtubeUrl = youtubeUrl; + } + + public void update( + String description, + String imageUrl, + String instagramUrl, + String youtubeUrl + ) { + this.description = description; + this.imageUrl = imageUrl; + this.instagramUrl = instagramUrl; + this.youtubeUrl = youtubeUrl; + } +} diff --git a/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java b/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java new file mode 100644 index 0000000..ba88f7c --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java @@ -0,0 +1,27 @@ +package com.dropit.sellerprofile.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum SellerProfileErrorCode implements ErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + SELLER_ROLE_REQUIRED(HttpStatus.FORBIDDEN, "판매자만 판매자 프로필을 등록할 수 있습니다."), + SELLER_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 판매자 프로필입니다."), + SELLER_PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 판매자 프로필이 존재합니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java b/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java new file mode 100644 index 0000000..815db35 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java @@ -0,0 +1,12 @@ +package com.dropit.sellerprofile.repository; + +import com.dropit.sellerprofile.entity.SellerProfile; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SellerProfileRepository extends JpaRepository { + + Optional findByUser_Id(Long userId); + + boolean existsByUser_Id(Long userId); +} diff --git a/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java new file mode 100644 index 0000000..a789b81 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java @@ -0,0 +1,103 @@ +package com.dropit.sellerprofile.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.entity.SellerProfile; +import com.dropit.sellerprofile.exception.SellerProfileErrorCode; +import com.dropit.sellerprofile.repository.SellerProfileRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SellerProfileService { + + private final SellerProfileRepository sellerProfileRepository; + private final UserRepository userRepository; + + @Transactional + public SellerProfileResponse create(Long userId, SellerProfileCreateRequest request) { + User user = findUser(userId); + + if (user.getRole() != UserRole.SELLER) { + throw new ServiceException( + SellerProfileErrorCode.SELLER_ROLE_REQUIRED + ); + } + + if (sellerProfileRepository.existsByUser_Id(userId)) { + throw new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS + ); + } + + SellerProfile sellerProfile = new SellerProfile( + user, + request.description(), + request.imageUrl(), + request.instagramUrl(), + request.youtubeUrl() + ); + + return SellerProfileResponse.from( + sellerProfileRepository.save(sellerProfile) + ); + } + + public SellerProfileResponse getMine(Long userId) { + return SellerProfileResponse.from(findByUserId(userId)); + } + + public SellerProfileResponse getById(Long sellerProfileId) { + SellerProfile sellerProfile = sellerProfileRepository.findById(sellerProfileId) + .orElseThrow(() -> new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_NOT_FOUND + )); + + return SellerProfileResponse.from(sellerProfile); + } + + @Transactional + public SellerProfileResponse update( + Long userId, + SellerProfileUpdateRequest request + ) { + SellerProfile sellerProfile = findByUserId(userId); + + sellerProfile.update( + request.description(), + request.imageUrl(), + request.instagramUrl(), + request.youtubeUrl() + ); + + return SellerProfileResponse.from(sellerProfile); + } + + @Transactional + public void delete(Long userId) { + SellerProfile sellerProfile = findByUserId(userId); + sellerProfileRepository.delete(sellerProfile); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ServiceException( + SellerProfileErrorCode.USER_NOT_FOUND + )); + } + + private SellerProfile findByUserId(Long userId) { + return sellerProfileRepository.findByUser_Id(userId) + .orElseThrow(() -> new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_NOT_FOUND + )); + } +} diff --git a/src/main/java/com/dropit/user/controller/UserController.java b/src/main/java/com/dropit/user/controller/UserController.java new file mode 100644 index 0000000..cb8c052 --- /dev/null +++ b/src/main/java/com/dropit/user/controller/UserController.java @@ -0,0 +1,67 @@ +package com.dropit.user.controller; + +import com.dropit.global.security.principal.CurrentUserId; +import com.dropit.user.dto.request.PasswordUpdateRequest; +import com.dropit.user.dto.request.UserUpdateRequest; +import com.dropit.user.dto.response.UserResponse; +import com.dropit.user.entity.User; +import com.dropit.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/users/me") + public UserResponse getMyInfo( + @CurrentUserId Long userId + ) { + User user = userService.findUser(userId); + + return UserResponse.from(user); + } + + @PatchMapping("/users/me") + public UserResponse updateMyProfile( + @CurrentUserId Long userId, + @Valid @RequestBody UserUpdateRequest request + ) { + User user = + userService.updateProfile(userId, request); + + return UserResponse.from(user); + } + + @PatchMapping("/users/me/password") + public ResponseEntity updateMyPassword( + @CurrentUserId Long userId, + @Valid @RequestBody PasswordUpdateRequest request + ) { + userService.updatePassword(userId, request); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/users/me") + public ResponseEntity deleteMyAccount( + @CurrentUserId Long userId + ) { + userService.deleteUser(userId); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/users/{userId}") + public ResponseEntity deleteUserByAdmin( + @PathVariable Long userId + ) { + userService.deleteUserByAdmin(userId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java b/src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java new file mode 100644 index 0000000..2803c5c --- /dev/null +++ b/src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java @@ -0,0 +1,15 @@ +package com.dropit.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordUpdateRequest( + + @NotBlank(message = "현재 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(min = 8, max = 255, message = "비밀번호는 8자 이상이어야 합니다.") + String newPassword +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java b/src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java new file mode 100644 index 0000000..1d0de4a --- /dev/null +++ b/src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java @@ -0,0 +1,12 @@ +package com.dropit.user.dto.request; + +import com.dropit.user.entity.UserRole; +import jakarta.validation.constraints.NotNull; + +public record UserRoleUpdateRequest( + + @NotNull(message = "사용자 역할은 필수입니다.") + UserRole role + +) { +} diff --git a/src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java b/src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java new file mode 100644 index 0000000..94b95fc --- /dev/null +++ b/src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java @@ -0,0 +1,13 @@ +package com.dropit.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest ( + @Email(message = "올바른 이메일 형식이어야 합니다." ) + String email, + @Size(min = 1, max = 100, message = "사용자 이름은 1자 이상 100자 이하여야 합니다.") + String username +){ + +} diff --git a/src/main/java/com/dropit/user/dto/response/UserResponse.java b/src/main/java/com/dropit/user/dto/response/UserResponse.java new file mode 100644 index 0000000..0afc8fe --- /dev/null +++ b/src/main/java/com/dropit/user/dto/response/UserResponse.java @@ -0,0 +1,27 @@ +package com.dropit.user.dto.response; + +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; + +import java.time.LocalDateTime; + +public record UserResponse( + Long id, + String email, + String username, + UserRole role, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + public static UserResponse from(User user) { + return new UserResponse( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getRole(), + user.getCreatedAt(), + user.getUpdatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/entity/User.java b/src/main/java/com/dropit/user/entity/User.java new file mode 100644 index 0000000..5ad4c98 --- /dev/null +++ b/src/main/java/com/dropit/user/entity/User.java @@ -0,0 +1,54 @@ +package com.dropit.user.entity; + +import com.dropit.global.entity.BaseSoftDeletableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseSoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, unique = true, length = 100) + private String username; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserRole role; + + public User(String email, String password, String username, UserRole role) { + this.email = email; + this.password = password; + this.username = username; + this.role = role; + } + + public void updateEmail(String email) { + this.email = email; + } + + public void updateUsername(String username) { + this.username = username; + } + + public void updatePassword(String password) { + this.password = password; + } + + public void updateRole(UserRole role) { + this.role = role; + } +} diff --git a/src/main/java/com/dropit/user/entity/UserRole.java b/src/main/java/com/dropit/user/entity/UserRole.java new file mode 100644 index 0000000..879beda --- /dev/null +++ b/src/main/java/com/dropit/user/entity/UserRole.java @@ -0,0 +1,11 @@ +package com.dropit.user.entity; + +public enum UserRole { + SELLER, + USER, + ADMIN; + + public String authority() { + return "ROLE_" + name(); + } +} diff --git a/src/main/java/com/dropit/user/exception/UserErrorCode.java b/src/main/java/com/dropit/user/exception/UserErrorCode.java new file mode 100644 index 0000000..5f634bb --- /dev/null +++ b/src/main/java/com/dropit/user/exception/UserErrorCode.java @@ -0,0 +1,42 @@ +package com.dropit.user.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + USER_NOT_FOUND( + HttpStatus.NOT_FOUND, + "존재하지 않는 사용자입니다." + ), + + EMAIL_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "이미 사용 중인 이메일입니다." + ), + + USERNAME_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "이미 사용 중인 사용자명입니다." + ), + + INVALID_CURRENT_PASSWORD( + HttpStatus.BAD_REQUEST, + "현재 비밀번호가 올바르지 않습니다." + ); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/repository/UserRepository.java b/src/main/java/com/dropit/user/repository/UserRepository.java new file mode 100644 index 0000000..dff813f --- /dev/null +++ b/src/main/java/com/dropit/user/repository/UserRepository.java @@ -0,0 +1,17 @@ +package com.dropit.user.repository; + +import com.dropit.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmailAndDeletedAtIsNull(String email); + + Optional findByIdAndDeletedAtIsNull(Long id); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/service/UserService.java b/src/main/java/com/dropit/user/service/UserService.java new file mode 100644 index 0000000..683b941 --- /dev/null +++ b/src/main/java/com/dropit/user/service/UserService.java @@ -0,0 +1,106 @@ +package com.dropit.user.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.user.dto.request.PasswordUpdateRequest; +import com.dropit.user.dto.request.UserUpdateRequest; +import com.dropit.user.entity.User; +import com.dropit.user.exception.UserErrorCode; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public User findUser(Long userId) { + return userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> + new ServiceException( + UserErrorCode.USER_NOT_FOUND + ) + ); + } + + @Transactional + public User updateProfile( + Long userId, + UserUpdateRequest request + ) { + User user = findUser(userId); + + if (request.email() != null + && !Objects.equals(user.getEmail(), request.email())) { + + if (userRepository.existsByEmail(request.email())) { + throw new ServiceException( + UserErrorCode.EMAIL_ALREADY_EXISTS + ); + } + + user.updateEmail(request.email()); + } + + if (request.username() != null + && !Objects.equals(user.getUsername(), request.username())) { + + if (userRepository.existsByUsername(request.username())) { + throw new ServiceException( + UserErrorCode.USERNAME_ALREADY_EXISTS + ); + } + + user.updateUsername(request.username()); + } + + return user; + } + + @Transactional + public void updatePassword( + Long userId, + PasswordUpdateRequest request + ) { + User user = findUser(userId); + + if (!passwordEncoder.matches( + request.currentPassword(), + user.getPassword() + )) { + throw new ServiceException( + UserErrorCode.INVALID_CURRENT_PASSWORD + ); + } + + user.updatePassword( + passwordEncoder.encode( + request.newPassword() + ) + ); + } + + @Transactional + public void deleteUser(Long userId) { + + User user = findUser(userId); + + user.markDeleted(); + } + + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public void deleteUserByAdmin(Long userId) { + User user = findUser(userId); + user.markDeleted(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 0000000..2fb7625 --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,8 @@ +spring.config.import=optional:file:.env.local[.properties] + +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +logging.level.com.dropit=DEBUG + +management.endpoint.health.show-details=always \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..72bd2a3 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,2 @@ +management.endpoint.health.show-details=never +spring.config.import=aws-parameterstore:/dropit-server/prod/ \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 731ca66..5eb7c85 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,16 @@ spring.application.name=dropit-server + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.open-in-view=false + +spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} + +jwt.secret=${JWT_SECRET} +jwt.expire.access=30m +jwt.expire.refresh=14d + +management.endpoints.web.exposure.include=health \ No newline at end of file diff --git a/src/test/java/com/dropit/DropitServerApplicationTests.java b/src/test/java/com/dropit/DropitServerApplicationTests.java index 50f61da..47b0580 100644 --- a/src/test/java/com/dropit/DropitServerApplicationTests.java +++ b/src/test/java/com/dropit/DropitServerApplicationTests.java @@ -1,13 +1,7 @@ package com.dropit; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class DropitServerApplicationTests { - - @Test - void contextLoads() { - } - } diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java new file mode 100644 index 0000000..dd2ee4f --- /dev/null +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -0,0 +1,264 @@ +package com.dropit.drop.controller; + +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.entity.DropStatus; +import com.dropit.drop.service.DropService; +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class DropControllerTest { + + @Mock + private DropService dropService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders + .standaloneSetup(new DropController(dropService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers( + new PageableHandlerMethodArgumentResolver(), + new AuthenticationPrincipalArgumentResolver() + ) + .setValidator(validator) + .build(); + authenticate(1L); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("원화 단위 가격으로 드랍 생성 요청 시 201 상태와 드랍 ID를 반환한다") + void createDrop() throws Exception { + when(dropService.save(eq(1L), any())).thenReturn(100L); + + mockMvc.perform(post("/drops") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "productId": 10, + "price": 59000, + "initialQuantity": 100, + "discountRate": 20, + "purchaseLimit": 2, + "openAt": "2026-09-01T12:00:00", + "closeAt": "2026-09-02T12:00:00" + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/drops/100")) + .andExpect(jsonPath("$").value(100)); + } + + @Test + @DisplayName("유효하지 않은 드랍 생성 요청은 400 오류를 반환한다") + void rejectInvalidCreateRequest() throws Exception { + mockMvc.perform(post("/drops") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "productId": 10, + "price": 0, + "initialQuantity": 0, + "discountRate": 101, + "openAt": "2026-09-01T12:00:00", + "closeAt": "2026-09-02T12:00:00" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT")) + .andExpect(jsonPath("$.errors.initialQuantity").exists()) + .andExpect(jsonPath("$.errors.price").exists()) + .andExpect(jsonPath("$.errors.discountRate").exists()) + .andExpect(jsonPath("$.errors.purchaseLimit").exists()); + } + + @Test + @DisplayName("드랍 목록과 상세 조회 요청 시 200 상태를 반환한다") + void getAllAndOneDrop() throws Exception { + DropResponse response = response(); + when(dropService.getAll( + any(DropSearchCondition.class), + any(Pageable.class) + )) + .thenReturn(new PageImpl<>( + List.of(response), + PageRequest.of(0, 20), + 1 + )); + when(dropService.getOne(100L)).thenReturn(response); + + mockMvc.perform(get("/drops")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100)); + mockMvc.perform(get("/drops/100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.productId").value(1)); + } + + @Test + @DisplayName("판매자별 공개 드랍 목록을 페이징하여 반환한다") + void getPublicDropsBySeller() throws Exception { + when(dropService.getPublicDropsBySeller(eq(10L), any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(response()), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/creators/10/drops") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.content[0].visible").value(true)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("판매자 관리용 드랍 목록을 페이징하여 반환한다") + void getDropsForSellerManagement() throws Exception { + authenticate(10L); + when(dropService.getDropsForSellerManagement(eq(10L), any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(response()), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/users/me/drops") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("드랍 수정 요청 시 200 상태를 반환한다") + void updateDrop() throws Exception { + when(dropService.update(eq(1L), eq(100L), any())).thenReturn(response()); + + mockMvc.perform(patch("/drops/100") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "price": 49000, + "initialQuantity": 100, + "discountRate": 30, + "purchaseLimit": 2, + "openAt": "2026-09-01T12:00:00", + "closeAt": "2026-09-02T12:00:00" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100)); + } + + @Test + @DisplayName("드랍 공개 여부 변경 요청 시 200 상태를 반환한다") + void changeVisibility() throws Exception { + when(dropService.changeVisibility(eq(1L), eq(100L), any())).thenReturn(response()); + + mockMvc.perform(patch("/drops/100/visibility") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "visible": true + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.visible").value(true)); + } + + @Test + @DisplayName("드랍 삭제 요청 시 204 상태를 반환한다") + void deleteDrop() throws Exception { + mockMvc.perform(delete("/drops/100")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("공개 여부가 없는 변경 요청은 400 오류를 반환한다") + void rejectVisibilityRequestWithoutVisible() throws Exception { + mockMvc.perform(patch("/drops/100/visibility") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT")) + .andExpect(jsonPath("$.errors.visible").exists()); + } + + private DropResponse response() { + LocalDateTime openAt = LocalDateTime.of(2026, 9, 1, 12, 0); + return new DropResponse( + 100L, + 10L, + "seller", + 1L, + "Limited Hoodie", + null, + new BigDecimal("59000"), + 20, + 100, + 100, + 0, + 2, + true, + openAt, + openAt.plusDays(1), + DropStatus.READY + ); + } + + private void authenticate(Long userId) { + JwtAuthenticationToken authentication = new JwtAuthenticationToken( + new AuthUser(userId), + List.of(new SimpleGrantedAuthority("ROLE_SELLER")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java new file mode 100644 index 0000000..e1a286e --- /dev/null +++ b/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java @@ -0,0 +1,101 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropCreateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("올바른 원화 단위 드랍 생성 요청은 검증을 통과한다") + void acceptValidRequest() { + DropCreateRequest request = createRequest(10, 20, 2); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("구매 제한 수량이 0인 생성 요청은 검증을 통과한다") + void acceptZeroPurchaseLimitAsUnlimited() { + DropCreateRequest request = createRequest(10, 20, 0); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("잘못된 수량, 할인율, 구매 제한 수량은 검증에 실패한다") + void rejectInvalidQuantityDiscountRateAndPurchaseLimit() { + DropCreateRequest request = createRequest(0, 101, -1); + + Set> violations = validator.validate(request); + + assertEquals(1, count(violations, "initialQuantity")); + assertEquals(1, count(violations, "discountRate")); + assertEquals(1, count(violations, "purchaseLimit")); + } + + @Test + @DisplayName("필수 상품, 가격, 판매 기간 정보가 없으면 검증에 실패한다") + void rejectMissingProductAndPeriod() { + DropCreateRequest request = new DropCreateRequest(null, null, 10, 20, null, null, null); + + Set> violations = validator.validate(request); + + assertEquals(1, count(violations, "productId")); + assertEquals(1, count(violations, "price")); + assertEquals(1, count(violations, "purchaseLimit")); + assertEquals(1, count(violations, "openAt")); + assertEquals(1, count(violations, "closeAt")); + } + + @Test + @DisplayName("원화 가격에 소수점이 포함되면 검증에 실패한다") + void rejectPriceWithFractionalWon() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + DropCreateRequest request = new DropCreateRequest( + 1L, + new BigDecimal("59000.50"), + 10, + 20, + 2, + openAt, + openAt.plusDays(1) + ); + + assertViolationCount(request, "price", 1); + } + + private DropCreateRequest createRequest(int quantity, int discountRate, Integer purchaseLimit) { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + return new DropCreateRequest( + 1L, + new BigDecimal("59000"), + quantity, + discountRate, + purchaseLimit, + openAt, + openAt.plusDays(1) + ); + } + + private long count(Set> violations, String property) { + return violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(property)) + .count(); + } + + private void assertViolationCount(DropCreateRequest request, String property, long expectedCount) { + assertEquals(expectedCount, count(validator.validate(request), property)); + } +} diff --git a/src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java new file mode 100644 index 0000000..a27598e --- /dev/null +++ b/src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java @@ -0,0 +1,53 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("원화 단위 가격을 포함한 드랍 수정 요청은 검증을 통과한다") + void acceptValidPrice() { + DropUpdateRequest request = new DropUpdateRequest( + new BigDecimal("49000"), + null, + null, + null, + null, + null + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("드랍 수정 가격에 소수점이 포함되면 검증에 실패한다") + void rejectPriceWithFractionalWon() { + DropUpdateRequest request = new DropUpdateRequest( + new BigDecimal("49000.50"), + null, + null, + null, + null, + null + ); + + Set> violations = validator.validate(request); + + long priceViolationCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals("price")) + .count(); + assertEquals(1, priceViolationCount); + } +} diff --git a/src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java new file mode 100644 index 0000000..df28695 --- /dev/null +++ b/src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java @@ -0,0 +1,38 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropVisibilityUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("공개 여부가 포함된 변경 요청은 검증을 통과한다") + void acceptVisibility() { + DropVisibilityUpdateRequest request = new DropVisibilityUpdateRequest(true); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("공개 여부가 없으면 변경 요청은 검증에 실패한다") + void rejectMissingVisibility() { + DropVisibilityUpdateRequest request = new DropVisibilityUpdateRequest(null); + + Set> violations = validator.validate(request); + + long visibleViolationCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals("visible")) + .count(); + assertEquals(1, visibleViolationCount); + } +} diff --git a/src/test/java/com/dropit/drop/entity/DropTest.java b/src/test/java/com/dropit/drop/entity/DropTest.java new file mode 100644 index 0000000..082f76a --- /dev/null +++ b/src/test/java/com/dropit/drop/entity/DropTest.java @@ -0,0 +1,117 @@ +package com.dropit.drop.entity; + +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class DropTest { + + private final Product product = mock(Product.class); + + @Test + @DisplayName("드랍 상태는 판매 기간과 재고를 기준으로 계산한다") + void calculateCurrentStatusFromPeriodAndStock() { + LocalDateTime openAt = LocalDateTime.of(2026, 9, 1, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt.plusHours(1)); + + assertEquals(DropStatus.READY, drop.currentStatus(openAt.minusSeconds(1))); + assertEquals(DropStatus.OPEN, drop.currentStatus(openAt)); + assertEquals(DropStatus.CLOSED, drop.currentStatus(openAt.plusHours(1))); + } + + @Test + @DisplayName("구매 제한 수량이 0이면 무제한으로 저장된다") + void acceptZeroPurchaseLimitAsUnlimited() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); + + assertEquals(0, drop.getPurchaseLimit()); + } + + @Test + @DisplayName("드랍은 생성 시 기본적으로 비공개 상태이다") + void defaultVisibilityIsFalse() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); + + assertEquals(false, drop.isVisible()); + } + + @Test + @DisplayName("드랍 공개 여부를 변경할 수 있다") + void changeVisibility() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); + + drop.changeVisibility(true); + + assertEquals(true, drop.isVisible()); + } + + @Test + @DisplayName("판매 시작 시각과 종료 시각이 같으면 드랍을 생성할 수 없다") + void rejectInvalidDropPeriodWithDropErrorCode() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt) + ); + + assertEquals(DropErrorCode.INVALID_DROP_PERIOD, exception.getErrorCode()); + } + + @Test + @DisplayName("판매 중인 공개 드랍의 재고를 차감하고 취소 시 복구할 수 있다") + void decreaseAndRestoreStock() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + + drop.decreaseStock(2, now); + assertEquals(8, drop.getRemainingQuantity()); + + drop.restoreStock(2); + assertEquals(10, drop.getRemainingQuantity()); + } + + @Test + @DisplayName("드랍 재고보다 많은 수량을 주문할 수 없다") + void rejectQuantityExceedingStock() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 1, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.decreaseStock(2, now) + ); + + assertEquals(DropErrorCode.INSUFFICIENT_STOCK, exception.getErrorCode()); + assertEquals(1, drop.getRemainingQuantity()); + } + + @Test + @DisplayName("비공개 드랍은 판매 기간이어도 주문할 수 없다") + void rejectHiddenDropPurchase() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.decreaseStock(1, now) + ); + + assertEquals(DropErrorCode.DROP_NOT_OPEN, exception.getErrorCode()); + } +} diff --git a/src/test/java/com/dropit/drop/service/DropServiceTest.java b/src/test/java/com/dropit/drop/service/DropServiceTest.java new file mode 100644 index 0000000..5744354 --- /dev/null +++ b/src/test/java/com/dropit/drop/service/DropServiceTest.java @@ -0,0 +1,363 @@ +package com.dropit.drop.service; + +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DropServiceTest { + + @Mock + private DropRepository dropRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private DropService dropService; + + @Test + @DisplayName("상품 소유자는 드랍을 생성할 수 있다") + void saveDropOwnedBySeller() { + Product product = saveProduct(1L, 10L); + DropCreateRequest request = saveRequest(); + when(productRepository.findById(10L)).thenReturn(Optional.of(product)); + when(dropRepository.save(any(Drop.class))).thenAnswer(invocation -> { + Drop drop = invocation.getArgument(0); + ReflectionTestUtils.setField(drop, "id", 100L); + return drop; + }); + + Long dropId = dropService.save(1L, request); + + assertEquals(100L, dropId); + ArgumentCaptor captor = ArgumentCaptor.forClass(Drop.class); + verify(dropRepository).save(captor.capture()); + Drop savedDrop = captor.getValue(); + assertEquals(product, savedDrop.getProduct()); + assertEquals(new BigDecimal("59000"), savedDrop.getPrice()); + assertEquals(10, savedDrop.getInitialQuantity()); + assertEquals(20, savedDrop.getDiscountRate()); + assertEquals(2, savedDrop.getPurchaseLimit()); + } + + @Test + @DisplayName("존재하지 않는 상품으로 드랍을 생성할 수 없다") + void rejectMissingProduct() { + when(productRepository.findById(10L)).thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.save(1L, saveRequest()) + ); + + assertEquals(ProductErrorCode.PRODUCT_NOT_FOUND, exception.getErrorCode()); + verify(dropRepository, never()).save(any()); + } + + @Test + @DisplayName("다른 판매자의 상품으로 드랍을 생성할 수 없다") + void rejectCreatingDropForAnotherSellersProduct() { + Product product = saveProduct(1L, 10L); + when(productRepository.findById(10L)).thenReturn(Optional.of(product)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.save(2L, saveRequest()) + ); + + assertEquals(ProductErrorCode.PRODUCT_OWNER_REQUIRED, exception.getErrorCode()); + verify(dropRepository, never()).save(any()); + } + + @Test + @DisplayName("공개된 드랍 상세 정보를 조회할 수 있다") + void getOneDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + DropResponse response = dropService.getOne(100L); + + assertEquals(100L, response.id()); + assertEquals(10L, response.productId()); + } + + @Test + @DisplayName("공개된 드랍 목록을 조회할 수 있다") + void getAllDrops() { + Drop first = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + Drop second = saveDrop(1L, 11L, 101L, LocalDateTime.now().plusDays(2)); + DropSearchCondition condition = new DropSearchCondition(null, null, null); + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.searchPublicDrops(condition, pageable)) + .thenReturn(new PageImpl<>(List.of(first, second), pageable, 2)); + + Page responses = dropService.getAll(condition, pageable); + + assertEquals(2, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + assertEquals(101L, responses.getContent().get(1).id()); + } + + @Test + @DisplayName("비공개 드랍은 공개 목록에서 제외된다") + void excludeHiddenDropsFromPublicList() { + Drop visibleDrop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + Product product = saveProduct(1L, 11L); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + ); + ReflectionTestUtils.setField(hiddenDrop, "id", 101L); + DropSearchCondition condition = new DropSearchCondition(null, null, null); + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.searchPublicDrops(condition, pageable)) + .thenReturn(new PageImpl<>(List.of(visibleDrop), pageable, 1)); + + Page responses = dropService.getAll(condition, pageable); + + assertEquals(1, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + } + + @Test + @DisplayName("판매자별 공개 드랍 목록을 페이징하여 조회한다") + void getPublicDropsBySeller() { + Long sellerId = 1L; + Drop drop = saveDrop(sellerId, 10L, 100L, LocalDateTime.now().plusDays(1)); + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.findAllByProductSellerIdAndVisibleTrue(sellerId, pageable)) + .thenReturn(new PageImpl<>(List.of(drop), pageable, 1)); + + Page responses = dropService.getPublicDropsBySeller(sellerId, pageable); + + assertEquals(1, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + assertEquals(sellerId, responses.getContent().get(0).sellerId()); + verify(dropRepository).findAllByProductSellerIdAndVisibleTrue(sellerId, pageable); + } + + @Test + @DisplayName("판매자 관리용 드랍 목록을 페이징하여 조회한다") + void getDropsForSellerManagement() { + Long sellerId = 1L; + Product product = saveProduct(sellerId, 10L); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + ); + ReflectionTestUtils.setField(hiddenDrop, "id", 100L); + + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.findAllByProductSellerId(sellerId, pageable)) + .thenReturn(new PageImpl<>(List.of(hiddenDrop), pageable, 1)); + + Page responses = dropService.getDropsForSellerManagement(sellerId, pageable); + + assertEquals(1, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + assertEquals(false, responses.getContent().get(0).visible()); + verify(dropRepository).findAllByProductSellerId(sellerId, pageable); + } + + @Test + @DisplayName("비공개 드랍 상세 조회는 존재하지 않는 드랍으로 처리한다") + void rejectHiddenDropFromPublicDetail() { + Product product = saveProduct(1L, 10L); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + ); + when(dropRepository.findById(100L)).thenReturn(Optional.of(hiddenDrop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.getOne(100L) + ); + + assertEquals(DropErrorCode.DROP_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("판매 시작 전 소유자는 드랍 가격과 판매 조건을 수정할 수 있다") + void updateOwnedDropBeforeOpen() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(2)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + DropUpdateRequest request = new DropUpdateRequest( + new BigDecimal("49000"), + 20, + 30, + 3, + LocalDateTime.now().plusDays(3), + LocalDateTime.now().plusDays(4) + ); + + DropResponse response = dropService.update(1L, 100L, request); + + assertEquals(20, response.initialQuantity()); + assertEquals(new BigDecimal("49000"), response.price()); + assertEquals(20, response.remainingQuantity()); + assertEquals(30, response.discountRate()); + assertEquals(3, response.purchaseLimit()); + verify(dropRepository, never()).save(any()); + } + + @Test + @DisplayName("구매 제한 수량은 0으로 변경하여 무제한으로 설정할 수 있다") + void updatePurchaseLimitToUnlimited() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(2)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + DropUpdateRequest request = new DropUpdateRequest( + null, + 10, + 20, + 0, + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3) + ); + + DropResponse response = dropService.update(1L, 100L, request); + + assertEquals(0, response.purchaseLimit()); + } + + @Test + @DisplayName("다른 판매자는 드랍을 수정할 수 없다") + void rejectUpdatingAnotherSellersDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.update(2L, 100L, new DropUpdateRequest( + null, + 10, + 30, + null, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + )) + ); + + assertEquals(DropErrorCode.DROP_OWNER_REQUIRED, exception.getErrorCode()); + } + + @Test + @DisplayName("드랍 소유자는 공개 여부를 변경할 수 있다") + void changeVisibilityOwnedBySeller() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + DropResponse response = dropService.changeVisibility( + 1L, + 100L, + new DropVisibilityUpdateRequest(false) + ); + + assertEquals(false, response.visible()); + } + + @Test + @DisplayName("다른 판매자는 드랍 공개 여부를 변경할 수 없다") + void rejectChangingVisibilityOfAnotherSellersDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.changeVisibility(2L, 100L, new DropVisibilityUpdateRequest(false)) + ); + + assertEquals(DropErrorCode.DROP_OWNER_REQUIRED, exception.getErrorCode()); + assertEquals(true, drop.isVisible()); + } + + @Test + @DisplayName("판매가 시작된 드랍은 삭제할 수 없다") + void rejectDeletingStartedDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().minusMinutes(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.delete(1L, 100L) + ); + + assertEquals(DropErrorCode.DROP_ALREADY_STARTED, exception.getErrorCode()); + verify(dropRepository, never()).delete(any()); + } + + private DropCreateRequest saveRequest() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + return new DropCreateRequest(10L, new BigDecimal("59000"), 10, 20, 2, openAt, openAt.plusDays(1)); + } + + private Drop saveDrop(Long sellerId, Long productId, Long dropId, LocalDateTime openAt) { + Product product = saveProduct(sellerId, productId); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt.plusDays(1)); + drop.changeVisibility(true); + ReflectionTestUtils.setField(drop, "id", dropId); + return drop; + } + + private Product saveProduct(Long sellerId, Long productId) { + User seller = new User("seller@example.com", "password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + Product product = new Product( + seller, + "Limited Hoodie", + "Limited edition hoodie", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + return product; + } +} diff --git a/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java b/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java new file mode 100644 index 0000000..291728e --- /dev/null +++ b/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java @@ -0,0 +1,80 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.SecurityErrorResponseSender; +import com.dropit.global.security.principal.AuthUser; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JwtFilterTest { + + private final JwtUtil jwtUtil = mock(JwtUtil.class); + private final SecurityErrorResponseSender errorResponseSender = mock(SecurityErrorResponseSender.class); + private final JwtFilter jwtFilter = new JwtFilter(jwtUtil, errorResponseSender); + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void requestWithoutBearerTokenContinuesWithoutAuthentication() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + jwtFilter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void validAccessTokenCreatesAuthenticatedSecurityContext() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + Claims claims = mock(Claims.class); + when(claims.getSubject()).thenReturn("42"); + when(claims.get(JwtUtil.USER_ROLE_CLAIM, String.class)).thenReturn("SELLER"); + when(jwtUtil.getAccessTokenClaims("valid-token")).thenReturn(claims); + + jwtFilter.doFilter(request, response, filterChain); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication.isAuthenticated()).isTrue(); + assertThat(authentication.getPrincipal()).isEqualTo(new AuthUser(42L)); + assertThat(authentication.getAuthorities()) + .extracting("authority") + .containsExactly("ROLE_SELLER"); + verify(filterChain).doFilter(request, response); + } + + @Test + void invalidTokenWritesErrorAndStopsFilterChain() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + ServiceException exception = new ServiceException(JwtErrorCode.JWT_TOKEN_ERROR); + when(jwtUtil.getAccessTokenClaims("invalid-token")).thenThrow(exception); + + jwtFilter.doFilter(request, response, filterChain); + + verify(errorResponseSender).send(response, exception); + verify(filterChain, never()).doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java b/src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java new file mode 100644 index 0000000..28daacd --- /dev/null +++ b/src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java @@ -0,0 +1,76 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ServiceException; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtUtilTest { + + private static final String SECRET = Base64.getEncoder().encodeToString( + "01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8) + ); + + private JwtUtil jwtUtil; + private User user; + + @BeforeEach + void setUp() { + JwtProperties properties = new JwtProperties( + SECRET, + new JwtProperties.Expire(Duration.ofMinutes(30), Duration.ofDays(14)) + ); + jwtUtil = new JwtUtil(properties); + + user = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(user, "id", 42L); + } + + @Test + void issueTokenPairCreatesAccessTokenWithUserIdAndRole() { + JwtTokenPair tokenPair = jwtUtil.issueTokenPair(user); + + Claims claims = jwtUtil.getAccessTokenClaims(tokenPair.accessToken()); + + assertThat(claims.getSubject()).isEqualTo("42"); + assertThat(claims.get(JwtUtil.TOKEN_TYPE_CLAIM, String.class)) + .isEqualTo(JwtUtil.TOKEN_TYPE_ACCESS); + assertThat(claims.get(JwtUtil.USER_ROLE_CLAIM, String.class)) + .isEqualTo(UserRole.SELLER.name()); + assertThat(claims.getId()).isNotBlank(); + assertThat(claims.getIssuedAt()).isBefore(claims.getExpiration()); + } + + @Test + void issueTokenPairCreatesRefreshTokenWithoutRole() { + JwtTokenPair tokenPair = jwtUtil.issueTokenPair(user); + + Claims claims = jwtUtil.getRefreshTokenClaims(tokenPair.refreshToken()); + + assertThat(claims.getSubject()).isEqualTo("42"); + assertThat(claims.get(JwtUtil.TOKEN_TYPE_CLAIM, String.class)) + .isEqualTo(JwtUtil.TOKEN_TYPE_REFRESH); + assertThat(claims.get(JwtUtil.USER_ROLE_CLAIM)).isNull(); + } + + @Test + void refreshTokenCannotBeUsedAsAccessToken() { + JwtTokenPair tokenPair = jwtUtil.issueTokenPair(user); + + assertThatThrownBy(() -> jwtUtil.getAccessTokenClaims(tokenPair.refreshToken())) + .isInstanceOfSatisfying(ServiceException.class, exception -> + assertThat(exception.getErrorCode()) + .isEqualTo(JwtErrorCode.JWT_INVALID_TOKEN_TYPE) + ); + } +} diff --git a/src/test/java/com/dropit/order/controller/OrderControllerTest.java b/src/test/java/com/dropit/order/controller/OrderControllerTest.java new file mode 100644 index 0000000..7b3bb34 --- /dev/null +++ b/src/test/java/com/dropit/order/controller/OrderControllerTest.java @@ -0,0 +1,143 @@ +package com.dropit.order.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.service.OrderService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class OrderControllerTest { + + private MockMvc mockMvc; + private OrderService orderService; + + @BeforeEach + void setUp() { + orderService = mock(OrderService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new OrderController(orderService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new JwtAuthenticationToken(new AuthUser(1L), List.of()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("배열 형태의 주문 항목으로 주문을 생성한다") + void createOrder() throws Exception { + OrderResponse response = new OrderResponse( + 1000L, + OrderStatus.ORDERED, + new BigDecimal("94400"), + null, + List.of() + ); + when(orderService.create(eq(1L), any(OrderCreateRequest.class))).thenReturn(response); + + mockMvc.perform(post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "items": [ + {"dropId": 100, "quantity": 2} + ] + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/orders/1000")) + .andExpect(jsonPath("$.id").value(1000L)) + .andExpect(jsonPath("$.status").value("ORDERED")) + .andExpect(jsonPath("$.totalPrice").value(94400)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrderCreateRequest.class); + verify(orderService).create(eq(1L), captor.capture()); + assertEquals(1, captor.getValue().items().size()); + assertEquals(100L, captor.getValue().items().getFirst().dropId()); + assertEquals(2, captor.getValue().items().getFirst().quantity()); + } + + @Test + @DisplayName("현재 MVP에서는 주문 항목을 두 개 이상 요청할 수 없다") + void rejectMultipleOrderItems() throws Exception { + mockMvc.perform(post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "items": [ + {"dropId": 100, "quantity": 1}, + {"dropId": 101, "quantity": 1} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT")); + + verifyNoInteractions(orderService); + } + + @Test + @DisplayName("내 주문 목록을 조회한다") + void getMyOrders() throws Exception { + when(orderService.getMyOrders(1L)).thenReturn(List.of( + new OrderSummaryResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null) + )); + + mockMvc.perform(get("/orders/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1000L)) + .andExpect(jsonPath("$[0].status").value("ORDERED")); + } + + @Test + @DisplayName("내 주문 상세를 조회한다") + void getMyOrder() throws Exception { + when(orderService.getMyOrder(1L, 1000L)).thenReturn( + new OrderResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null, List.of()) + ); + + mockMvc.perform(get("/orders/{orderId}", 1000L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1000L)) + .andExpect(jsonPath("$.totalPrice").value(94400)); + } + + @Test + @DisplayName("내 주문을 취소한다") + void cancelOrder() throws Exception { + mockMvc.perform(post("/orders/{orderId}/cancel", 1000L)) + .andExpect(status().isNoContent()); + + verify(orderService).cancel(1L, 1000L); + } +} diff --git a/src/test/java/com/dropit/order/entity/OrderItemTest.java b/src/test/java/com/dropit/order/entity/OrderItemTest.java new file mode 100644 index 0000000..e0e87a4 --- /dev/null +++ b/src/test/java/com/dropit/order/entity/OrderItemTest.java @@ -0,0 +1,95 @@ +package com.dropit.order.entity; + +import com.dropit.drop.entity.Drop; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class OrderItemTest { + + @Test + @DisplayName("주문 항목은 정가와 할인율을 스냅샷으로 저장하고 할인 적용 금액을 계산한다") + void createOrderItemWithPriceSnapshotsAndDiscountedTotal() { + User buyer = new User("buyer@example.com", "encoded-password", "buyer", UserRole.USER); + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + Drop drop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 0, + LocalDateTime.of(2026, 9, 3, 12, 0), + LocalDateTime.of(2026, 9, 3, 13, 0) + ); + Order order = new Order(buyer, new BigDecimal("94400")); + + OrderItem orderItem = new OrderItem( + order, + drop, + product.getName(), + drop.getPrice(), + drop.getDiscountRate(), + 2 + ); + + assertAll( + () -> assertEquals(order, orderItem.getOrder()), + () -> assertEquals(drop, orderItem.getDrop()), + () -> assertEquals("Limited Hoodie", orderItem.getProductName()), + () -> assertEquals(new BigDecimal("59000"), orderItem.getUnitPrice()), + () -> assertEquals(20, orderItem.getDiscountRate()), + () -> assertEquals(2, orderItem.getQuantity()), + () -> assertEquals(new BigDecimal("94400"), orderItem.getItemTotalPrice()) + ); + } + + @Test + @DisplayName("할인율이 0이면 정가와 수량으로 주문 항목 금액을 계산한다") + void calculateItemTotalPriceWithoutDiscount() { + OrderItem orderItem = createOrderItem(new BigDecimal("59000"), 0, 2); + + assertEquals(new BigDecimal("118000"), orderItem.getItemTotalPrice()); + } + + @Test + @DisplayName("할인 적용 금액에 원 단위 미만이 생기면 버림 처리한다") + void roundDownFractionalWonInItemTotalPrice() { + OrderItem orderItem = createOrderItem(new BigDecimal("9999"), 10, 1); + + assertEquals(new BigDecimal("8999"), orderItem.getItemTotalPrice()); + } + + private OrderItem createOrderItem(BigDecimal price, int discountRate, int quantity) { + User buyer = new User("buyer@example.com", "encoded-password", "buyer", UserRole.USER); + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + Drop drop = new Drop( + product, + price, + 10, + discountRate, + 0, + LocalDateTime.of(2026, 9, 3, 12, 0), + LocalDateTime.of(2026, 9, 3, 13, 0) + ); + Order order = new Order(buyer, BigDecimal.ZERO); + + return new OrderItem( + order, + drop, + product.getName(), + drop.getPrice(), + drop.getDiscountRate(), + quantity + ); + } +} diff --git a/src/test/java/com/dropit/order/entity/OrderTest.java b/src/test/java/com/dropit/order/entity/OrderTest.java new file mode 100644 index 0000000..6440d7c --- /dev/null +++ b/src/test/java/com/dropit/order/entity/OrderTest.java @@ -0,0 +1,48 @@ +package com.dropit.order.entity; + +import com.dropit.global.exception.ServiceException; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class OrderTest { + + private final User user = mock(User.class); + + @Test + @DisplayName("주문은 주문 완료 상태로 생성된다") + void createOrderAsOrdered() { + Order order = new Order(user, new BigDecimal("94400")); + + assertEquals(OrderStatus.ORDERED, order.getStatus()); + assertEquals(new BigDecimal("94400"), order.getTotalPrice()); + } + + @Test + @DisplayName("주문을 취소 상태로 변경할 수 있다") + void cancelOrder() { + Order order = new Order(user, new BigDecimal("94400")); + + order.cancel(); + + assertEquals(OrderStatus.CANCELED, order.getStatus()); + } + + @Test + @DisplayName("이미 취소한 주문은 다시 취소할 수 없다") + void rejectAlreadyCanceledOrder() { + Order order = new Order(user, new BigDecimal("94400")); + order.cancel(); + + ServiceException exception = assertThrows(ServiceException.class, order::cancel); + + assertEquals(OrderErrorCode.ORDER_ALREADY_CANCELED, exception.getErrorCode()); + } +} diff --git a/src/test/java/com/dropit/order/service/OrderServiceTest.java b/src/test/java/com/dropit/order/service/OrderServiceTest.java new file mode 100644 index 0000000..5c8fd89 --- /dev/null +++ b/src/test/java/com/dropit/order/service/OrderServiceTest.java @@ -0,0 +1,193 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.request.OrderItemCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock private UserRepository userRepository; + @Mock private DropRepository dropRepository; + @Mock private OrderRepository orderRepository; + @Mock private OrderItemRepository orderItemRepository; + @InjectMocks private OrderService orderService; + + @Test + @DisplayName("주문을 생성하면 재고를 차감하고 주문 시점 정보를 저장한다") + void createOrder() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + OrderCreateRequest request = new OrderCreateRequest(List.of(new OrderItemCreateRequest(100L, 2))); + + when(userRepository.findById(1L)).thenReturn(Optional.of(buyer)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + when(orderItemRepository.sumQuantityByUserAndDropAndStatus(1L, 100L, OrderStatus.ORDERED)) + .thenReturn(0L); + when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> { + Order order = invocation.getArgument(0); + ReflectionTestUtils.setField(order, "id", 1000L); + return order; + }); + when(orderItemRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + OrderResponse response = orderService.create(1L, request); + + assertEquals(1000L, response.id()); + assertEquals(OrderStatus.ORDERED, response.status()); + assertEquals(new BigDecimal("94400"), response.totalPrice()); + assertEquals(8, drop.getRemainingQuantity()); + assertEquals(1, response.items().size()); + assertEquals("Limited Hoodie", response.items().getFirst().productName()); + assertEquals(new BigDecimal("59000"), response.items().getFirst().unitPrice()); + assertEquals(20, response.items().getFirst().discountRate()); + assertEquals(new BigDecimal("94400"), response.items().getFirst().itemTotalPrice()); + } + + @Test + @DisplayName("기존 구매 수량과 새 수량의 합이 구매 제한을 넘으면 주문할 수 없다") + void rejectPurchaseLimitExceeded() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + OrderCreateRequest request = new OrderCreateRequest(List.of(new OrderItemCreateRequest(100L, 2))); + + when(userRepository.findById(1L)).thenReturn(Optional.of(buyer)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + when(orderItemRepository.sumQuantityByUserAndDropAndStatus(1L, 100L, OrderStatus.ORDERED)) + .thenReturn(1L); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> orderService.create(1L, request) + ); + + assertEquals(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED, exception.getErrorCode()); + assertEquals(10, drop.getRemainingQuantity()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("내 주문 목록을 최신순 조회 결과로 반환한다") + void getMyOrders() { + User buyer = createUser(1L, UserRole.USER); + Order order = new Order(buyer, new BigDecimal("94400")); + ReflectionTestUtils.setField(order, "id", 1000L); + when(orderRepository.findAllByUser_IdOrderByCreatedAtDesc(1L)).thenReturn(List.of(order)); + + List responses = orderService.getMyOrders(1L); + + assertEquals(1, responses.size()); + assertEquals(1000L, responses.getFirst().id()); + assertEquals(new BigDecimal("94400"), responses.getFirst().totalPrice()); + } + + @Test + @DisplayName("내 주문 상세는 주문 항목을 함께 반환한다") + void getMyOrder() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + Order order = new Order(buyer, new BigDecimal("94400")); + ReflectionTestUtils.setField(order, "id", 1000L); + OrderItem orderItem = new OrderItem(order, drop, "Limited Hoodie", new BigDecimal("59000"), 20, 2); + + when(orderRepository.findByIdAndUser_Id(1000L, 1L)).thenReturn(Optional.of(order)); + when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of(orderItem)); + + OrderResponse response = orderService.getMyOrder(1L, 1000L); + + assertEquals(1000L, response.id()); + assertEquals(1, response.items().size()); + assertEquals(100L, response.items().getFirst().dropId()); + } + + @Test + @DisplayName("본인 주문이 아니면 주문 상세를 조회할 수 없다") + void rejectAnotherUsersOrder() { + when(orderRepository.findByIdAndUser_Id(1000L, 2L)).thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> orderService.getMyOrder(2L, 1000L) + ); + + assertEquals(OrderErrorCode.ORDER_NOT_FOUND, exception.getErrorCode()); + verify(orderItemRepository, never()).findAllByOrder_IdOrderByIdAsc(any()); + } + + @Test + @DisplayName("주문을 취소하면 주문 상태를 변경하고 재고를 복구한다") + void cancelOrder() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + drop.decreaseStock(2, LocalDateTime.now()); + Order order = new Order(buyer, new BigDecimal("94400")); + ReflectionTestUtils.setField(order, "id", 1000L); + OrderItem orderItem = new OrderItem(order, drop, "Limited Hoodie", new BigDecimal("59000"), 20, 2); + + when(orderRepository.findByIdAndUser_Id(1000L, 1L)).thenReturn(Optional.of(order)); + when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of(orderItem)); + + orderService.cancel(1L, 1000L); + + assertEquals(OrderStatus.CANCELED, order.getStatus()); + assertEquals(10, drop.getRemainingQuantity()); + } + + private User createUser(Long id, UserRole role) { + User user = new User("user@example.com", "password", "user", role); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Drop createOpenDrop(Long sellerId, Long dropId, int stock, int discountRate, int purchaseLimit) { + User seller = createUser(sellerId, UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + ReflectionTestUtils.setField(product, "id", 10L); + Drop drop = new Drop( + product, + new BigDecimal("59000"), + stock, + discountRate, + purchaseLimit, + LocalDateTime.now().minusHours(1), + LocalDateTime.now().plusHours(1) + ); + ReflectionTestUtils.setField(drop, "id", dropId); + drop.changeVisibility(true); + return drop; + } +} diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java new file mode 100644 index 0000000..976f2d8 --- /dev/null +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -0,0 +1,381 @@ +package com.dropit.product.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.service.ProductService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ProductControllerTest { + + private MockMvc mockMvc; + private ProductService productService; + + @BeforeEach + void setUp() { + productService = mock(ProductService.class); + ProductController productController = new ProductController(productService); + mockMvc = MockMvcBuilders + .standaloneSetup(productController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers( + new PageableHandlerMethodArgumentResolver(), + new AuthenticationPrincipalArgumentResolver() + ) + .build(); + authenticate(1L); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("상품을 등록하면 201 상태와 생성된 상품 ID를 반환한다") + void create() throws Exception { + Long sellerId = 1L; + when(productService.create(eq(sellerId), any(ProductCreateRequest.class))) + .thenReturn(100L); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Limited Hoodie", + "description": "Limited edition hoodie" + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/products/100")) + .andExpect(content().string("100")); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ProductCreateRequest.class); + verify(productService).create(eq(sellerId), requestCaptor.capture()); + + ProductCreateRequest request = requestCaptor.getValue(); + assertEquals("Limited Hoodie", request.getName()); + assertEquals("Limited edition hoodie", request.getDescription()); + } + + @Test + @DisplayName("요청 파라미터가 아니라 로그인 사용자의 ID로 상품을 등록한다") + void useAuthenticatedSellerId() throws Exception { + when(productService.create(eq(1L), any(ProductCreateRequest.class))) + .thenReturn(100L); + + mockMvc.perform(post("/products") + .param("sellerId", "999") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "description": "Description" + } + """)) + .andExpect(status().isCreated()); + + verify(productService).create(eq(1L), any(ProductCreateRequest.class)); + } + + @Test + @DisplayName("상품명이 공백이면 400 상태를 반환한다") + void rejectBlankName() throws Exception { + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": " ", + "description": "Description" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("요청 본문이 없으면 400 상태를 반환한다") + void rejectMissingBody() throws Exception { + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("존재하지 않는 판매자로 상품을 등록하면 404 오류 응답을 반환한다") + void rejectMissingSeller() throws Exception { + authenticate(999L); + when(productService.create(eq(999L), any(ProductCreateRequest.class))) + .thenThrow(new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "description": "Description" + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("SELLER_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("존재하지 않는 판매자입니다.")); + } + + @Test + @DisplayName("판매자 역할이 아니면 상품 등록 시 403 오류 응답을 반환한다") + void rejectNonSeller() throws Exception { + when(productService.create(eq(1L), any(ProductCreateRequest.class))) + .thenThrow(new ServiceException(ProductErrorCode.SELLER_ROLE_REQUIRED)); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "description": "Description" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("SELLER_ROLE_REQUIRED")) + .andExpect(jsonPath("$.message").value("판매자만 상품을 등록할 수 있습니다.")); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면 404 오류 응답을 반환한다") + void rejectMissingProduct() throws Exception { + when(productService.getProduct(999L)) + .thenThrow(new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + mockMvc.perform(get("/products/{productId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PRODUCT_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("존재하지 않는 상품입니다.")); + } + + @Test + @DisplayName("전체 상품 목록을 조회하면 200 상태와 상품 목록을 반환한다") + void getProducts() throws Exception { + User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product product = new Product( + seller, + "Limited Hoodie", + "Limited edition hoodie", + null + ); + ReflectionTestUtils.setField(product, "id", 100L); + + when(productService.getProducts(any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(new ProductResponse(product)), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/products")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.content[0].sellerId").value(1L)) + .andExpect(jsonPath("$.content[0].sellerName").value("seller")) + .andExpect(jsonPath("$.content[0].name").value("Limited Hoodie")) + .andExpect(jsonPath("$.content[0].price").doesNotExist()) + .andExpect(jsonPath("$.content[0].description").value("Limited edition hoodie")); + + verify(productService).getProducts(any(Pageable.class)); + } + + @Test + @DisplayName("판매자별 상품 목록을 페이징하여 반환한다") + void getProductsBySeller() throws Exception { + User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product product = new Product( + seller, + "Seller Product", + "Seller product description", + null + ); + ReflectionTestUtils.setField(product, "id", 100L); + + when(productService.getProductsBySeller(eq(1L), any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(new ProductResponse(product)), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/sellers/1/products") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.content[0].sellerId").value(1L)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + + verify(productService).getProductsBySeller(eq(1L), any(Pageable.class)); + } + + @Test + @DisplayName("상품을 수정하면 200 상태와 수정된 상품 정보를 반환한다") + void updateProduct() throws Exception { + Long sellerId = 1L; + Long productId = 100L; + User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Updated Product", + "Updated description", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + + when(productService.update(eq(sellerId), eq(productId), any(ProductUpdateRequest.class))) + .thenReturn(new ProductResponse(product)); + + mockMvc.perform(put("/products/{productId}", productId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Updated Product", + "description": "Updated description" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(productId)) + .andExpect(jsonPath("$.sellerId").value(sellerId)) + .andExpect(jsonPath("$.name").value("Updated Product")) + .andExpect(jsonPath("$.price").doesNotExist()) + .andExpect(jsonPath("$.description").value("Updated description")); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ProductUpdateRequest.class); + verify(productService).update(eq(sellerId), eq(productId), requestCaptor.capture()); + + ProductUpdateRequest request = requestCaptor.getValue(); + assertEquals("Updated Product", request.getName()); + assertEquals("Updated description", request.getDescription()); + } + + @Test + @DisplayName("상품 수정 요청의 상품명이 공백이면 400 상태를 반환한다") + void rejectUpdatingWithBlankName() throws Exception { + mockMvc.perform(put("/products/{productId}", 100L) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": " ", + "description": "Updated description" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("상품 소유자가 아니면 수정 시 403 오류 응답을 반환한다") + void rejectUpdatingAnotherSellersProduct() throws Exception { + authenticate(2L); + when(productService.update(eq(2L), eq(100L), any(ProductUpdateRequest.class))) + .thenThrow(new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED)); + + mockMvc.perform(put("/products/{productId}", 100L) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Updated Product", + "description": "Updated description" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PRODUCT_OWNER_REQUIRED")) + .andExpect(jsonPath("$.message").value("상품 소유자만 변경할 수 있습니다.")); + } + + @Test + @DisplayName("상품을 삭제하면 204 상태를 반환한다") + void deleteProduct() throws Exception { + Long sellerId = 1L; + Long productId = 100L; + + mockMvc.perform(delete("/products/{productId}", productId)) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + verify(productService).delete(sellerId, productId); + } + + private void authenticate(Long userId) { + JwtAuthenticationToken authentication = new JwtAuthenticationToken( + new AuthUser(userId), + List.of(new SimpleGrantedAuthority("ROLE_SELLER")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java b/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java new file mode 100644 index 0000000..6d0323e --- /dev/null +++ b/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java @@ -0,0 +1,64 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProductCreateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("올바른 상품 등록 요청은 검증을 통과한다") + void acceptValidRequest() { + ProductCreateRequest request = new ProductCreateRequest( + "Limited Hoodie", + "Limited edition hoodie" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("상품명은 공백일 수 없다") + void rejectBlankName() { + ProductCreateRequest request = new ProductCreateRequest( + " ", + null + ); + + assertViolationCount(request, "name", 1); + } + + @Test + @DisplayName("상품명은 100자를 초과할 수 없다") + void rejectTooLongName() { + ProductCreateRequest request = new ProductCreateRequest( + "a".repeat(101), + null + ); + + assertViolationCount(request, "name", 1); + } + + private void assertViolationCount( + ProductCreateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java b/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java new file mode 100644 index 0000000..9d015fa --- /dev/null +++ b/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java @@ -0,0 +1,64 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProductUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("올바른 상품 수정 요청은 검증을 통과한다") + void acceptValidRequest() { + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + "Updated description" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("상품 수정 시 상품명은 공백일 수 없다") + void rejectBlankName() { + ProductUpdateRequest request = new ProductUpdateRequest( + " ", + null + ); + + assertViolationCount(request, "name", 1); + } + + @Test + @DisplayName("상품 수정 시 상품 설명은 3000자를 초과할 수 없다") + void rejectTooLongDescription() { + ProductUpdateRequest request = new ProductUpdateRequest( + "Product", + "a".repeat(3001) + ); + + assertViolationCount(request, "description", 1); + } + + private void assertViolationCount( + ProductUpdateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/product/entity/ProductTest.java b/src/test/java/com/dropit/product/entity/ProductTest.java new file mode 100644 index 0000000..09eeae2 --- /dev/null +++ b/src/test/java/com/dropit/product/entity/ProductTest.java @@ -0,0 +1,64 @@ +package com.dropit.product.entity; + +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ProductTest { + + private final User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + + @Test + @DisplayName("상품을 생성하면 입력한 정보가 저장된다") + void createProduct() { + Product product = new Product( + seller, + "Limited Hoodie", + "Limited edition hoodie", + "products/limited-hoodie.webp" + ); + + assertEquals(seller, product.getSeller()); + assertEquals("Limited Hoodie", product.getName()); + assertEquals("Limited edition hoodie", product.getDescription()); + assertEquals("products/limited-hoodie.webp", product.getImageUrl()); + } + + @Test + @DisplayName("상품의 기본 정보를 수정할 수 있다") + void updateInfo() { + Product product = createProductWithoutImage(); + + product.updateInfo("Updated Product", "Updated description"); + + assertEquals("Updated Product", product.getName()); + assertEquals("Updated description", product.getDescription()); + } + + @Test + @DisplayName("대표 이미지를 변경할 수 있다") + void changeImage() { + Product product = createProductWithoutImage(); + + product.changeImage("products/new-image.webp"); + + assertEquals("products/new-image.webp", product.getImageUrl()); + } + + private Product createProductWithoutImage() { + return new Product( + seller, + "Product", + "Description", + null + ); + } +} diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java new file mode 100644 index 0000000..cb4e2ad --- /dev/null +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -0,0 +1,374 @@ +package com.dropit.product.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.function.Executable; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ProductService productService; + + @Test + @DisplayName("판매자는 상품을 등록할 수 있다") + void createProduct() { + Long sellerId = 1L; + User seller = createUser(UserRole.SELLER); + ProductCreateRequest request = new ProductCreateRequest( + "Limited Hoodie", + "Limited edition hoodie" + ); + + when(userRepository.findById(sellerId)).thenReturn(Optional.of(seller)); + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + ReflectionTestUtils.setField(product, "id", 100L); + return product; + }); + + Long productId = productService.create(sellerId, request); + + assertEquals(100L, productId); + + ArgumentCaptor productCaptor = ArgumentCaptor.forClass(Product.class); + verify(productRepository).save(productCaptor.capture()); + + Product savedProduct = productCaptor.getValue(); + assertEquals(seller, savedProduct.getSeller()); + assertEquals("Limited Hoodie", savedProduct.getName()); + assertEquals("Limited edition hoodie", savedProduct.getDescription()); + assertNull(savedProduct.getImageUrl()); + } + + @Test + @DisplayName("존재하지 않는 사용자는 상품을 등록할 수 없다") + void rejectMissingUser() { + Long sellerId = 1L; + ProductCreateRequest request = createRequest(); + when(userRepository.findById(sellerId)).thenReturn(Optional.empty()); + + assertServiceException( + ProductErrorCode.SELLER_NOT_FOUND, + () -> productService.create(sellerId, request) + ); + + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("일반 사용자는 상품을 등록할 수 없다") + void rejectNonSeller() { + Long userId = 1L; + User user = createUser(UserRole.USER); + ProductCreateRequest request = createRequest(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertServiceException( + ProductErrorCode.SELLER_ROLE_REQUIRED, + () -> productService.create(userId, request) + ); + + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("상품 ID로 상품 상세 정보를 조회한다") + void getProduct() { + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product product = new Product( + seller, + "Limited Hoodie", + "Limited edition hoodie", + "products/limited-hoodie.webp" + ); + ReflectionTestUtils.setField(product, "id", productId); + + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + ProductResponse response = productService.getProduct(productId); + + assertEquals(productId, response.getId()); + assertEquals(1L, response.getSellerId()); + assertEquals("username", response.getSellerName()); + assertEquals("Limited Hoodie", response.getName()); + assertEquals("Limited edition hoodie", response.getDescription()); + assertEquals("products/limited-hoodie.webp", response.getImageUrl()); + } + + @Test + @DisplayName("존재하지 않는 상품은 조회할 수 없다") + void rejectMissingProduct() { + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + assertServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND, + () -> productService.getProduct(productId) + ); + } + + @Test + @DisplayName("전체 상품 목록을 조회한다") + void getProducts() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product firstProduct = new Product( + seller, + "First Product", + "First description", + null + ); + Product secondProduct = new Product( + seller, + "Second Product", + "Second description", + null + ); + ReflectionTestUtils.setField(firstProduct, "id", 1L); + ReflectionTestUtils.setField(secondProduct, "id", 2L); + + Pageable pageable = PageRequest.of(0, 20); + when(productRepository.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(firstProduct, secondProduct), pageable, 2)); + + Page response = productService.getProducts(pageable); + + assertEquals(2, response.getTotalElements()); + assertEquals("First Product", response.getContent().get(0).getName()); + assertEquals("Second Product", response.getContent().get(1).getName()); + verify(productRepository).findAll(pageable); + } + + @Test + @DisplayName("판매자별 상품 목록을 페이징하여 조회한다") + void getProductsBySeller() { + Long sellerId = 1L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Seller Product", + "Seller product description", + null + ); + ReflectionTestUtils.setField(product, "id", 100L); + + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.findById(sellerId)).thenReturn(Optional.of(seller)); + when(productRepository.findAllBySellerId(sellerId, pageable)) + .thenReturn(new PageImpl<>(List.of(product), pageable, 1)); + + Page response = productService.getProductsBySeller(sellerId, pageable); + + assertEquals(1, response.getTotalElements()); + assertEquals(100L, response.getContent().get(0).getId()); + assertEquals(sellerId, response.getContent().get(0).getSellerId()); + verify(productRepository).findAllBySellerId(sellerId, pageable); + } + + @Test + @DisplayName("상품 소유자는 상품 정보를 수정할 수 있다") + void updateProduct() { + Long sellerId = 1L; + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Original Product", + "Original description", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + "Updated description" + ); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + ProductResponse response = productService.update(sellerId, productId, request); + + assertEquals("Updated Product", product.getName()); + assertEquals("Updated description", product.getDescription()); + assertEquals("Updated Product", response.getName()); + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품은 수정할 수 없다") + void rejectUpdatingMissingProduct() { + Long productId = 999L; + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + null + ); + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + assertServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND, + () -> productService.update(1L, productId, request) + ); + } + + @Test + @DisplayName("상품 소유자가 아니면 상품을 수정할 수 없다") + void rejectUpdatingAnotherSellersProduct() { + Long ownerId = 1L; + Long otherSellerId = 2L; + Long productId = 100L; + User owner = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Product product = new Product( + owner, + "Original Product", + "Original description", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + "Updated description" + ); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + assertServiceException( + ProductErrorCode.PRODUCT_OWNER_REQUIRED, + () -> productService.update(otherSellerId, productId, request) + ); + + assertEquals("Original Product", product.getName()); + assertEquals("Original description", product.getDescription()); + } + + @Test + @DisplayName("상품 소유자는 상품을 삭제할 수 있다") + void deleteProduct() { + Long sellerId = 1L; + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Product", + null, + null + ); + ReflectionTestUtils.setField(product, "id", productId); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + productService.delete(sellerId, productId); + + verify(productRepository).delete(product); + } + + @Test + @DisplayName("존재하지 않는 상품은 삭제할 수 없다") + void rejectDeletingMissingProduct() { + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + assertServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND, + () -> productService.delete(1L, productId) + ); + + verify(productRepository, never()).delete(any(Product.class)); + } + + @Test + @DisplayName("상품 소유자가 아니면 상품을 삭제할 수 없다") + void rejectDeletingAnotherSellersProduct() { + Long ownerId = 1L; + Long otherSellerId = 2L; + Long productId = 100L; + User owner = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Product product = new Product( + owner, + "Product", + null, + null + ); + ReflectionTestUtils.setField(product, "id", productId); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + assertServiceException( + ProductErrorCode.PRODUCT_OWNER_REQUIRED, + () -> productService.delete(otherSellerId, productId) + ); + + verify(productRepository, never()).delete(any(Product.class)); + } + + private ProductCreateRequest createRequest() { + return new ProductCreateRequest( + "Product", + "Description" + ); + } + + private void assertServiceException(ProductErrorCode errorCode, Executable executable) { + ServiceException exception = assertThrows(ServiceException.class, executable); + + assertEquals(errorCode, exception.getErrorCode()); + } + + private User createUser(UserRole role) { + return new User( + "user@example.com", + "encoded-password", + "username", + role + ); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java new file mode 100644 index 0000000..7856758 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java @@ -0,0 +1,171 @@ +package com.dropit.sellerprofile.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.exception.SellerProfileErrorCode; +import com.dropit.sellerprofile.service.SellerProfileService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class SellerProfileControllerTest { + + private MockMvc mockMvc; + private SellerProfileService sellerProfileService; + + @BeforeEach + void setUp() { + sellerProfileService = mock(SellerProfileService.class); + SellerProfileController controller = new SellerProfileController(sellerProfileService); + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + SecurityContextHolder.getContext().setAuthentication( + new JwtAuthenticationToken(new AuthUser(1L), List.of()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("판매자 프로필을 등록하면 201 상태를 반환한다") + void create() throws Exception { + SellerProfileResponse response = + new SellerProfileResponse(100L, 1L, "판매자 소개", null, null, null); + + when(sellerProfileService.create(eq(1L), any(SellerProfileCreateRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/seller-profiles") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "판매자 소개" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(100L)) + .andExpect(jsonPath("$.userId").value(1L)) + .andExpect(jsonPath("$.description").value("판매자 소개")); + } + + @Test + @DisplayName("일반 사용자는 프로필 등록 시 403 오류 응답을 반환한다") + void rejectNonSeller() throws Exception { + when(sellerProfileService.create(eq(1L), any(SellerProfileCreateRequest.class))) + .thenThrow(new ServiceException(SellerProfileErrorCode.SELLER_ROLE_REQUIRED)); + + mockMvc.perform(post("/seller-profiles") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "판매자 소개" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("SELLER_ROLE_REQUIRED")); + } + + @Test + @DisplayName("중복 판매자 프로필 등록 시 409 오류 응답을 반환한다") + void rejectDuplicateProfile() throws Exception { + when(sellerProfileService.create(eq(1L), any(SellerProfileCreateRequest.class))) + .thenThrow(new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS)); + + mockMvc.perform(post("/seller-profiles") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "판매자 소개" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("SELLER_PROFILE_ALREADY_EXISTS")); + } + + @Test + @DisplayName("내 판매자 프로필을 조회하면 200 상태를 반환한다") + void getMine() throws Exception { + when(sellerProfileService.getMine(1L)) + .thenReturn(new SellerProfileResponse( + 100L, 1L, "판매자 소개", null, null, null)); + + mockMvc.perform(get("/seller-profiles/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100L)); + } + + @Test + @DisplayName("판매자 프로필 ID로 공개 프로필을 조회할 수 있다") + void getById() throws Exception { + when(sellerProfileService.getById(100L)) + .thenReturn(new SellerProfileResponse( + 100L, 1L, "판매자 소개", null, null, null)); + + mockMvc.perform(get("/seller-profiles/{sellerProfileId}", 100L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100L)); + } + + @Test + @DisplayName("판매자 프로필을 수정하면 200 상태를 반환한다") + void update() throws Exception { + when(sellerProfileService.update(eq(1L), any(SellerProfileUpdateRequest.class))) + .thenReturn(new SellerProfileResponse( + 100L, 1L, "수정된 소개", null, null, null)); + + mockMvc.perform(patch("/seller-profiles/me") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "수정된 소개" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description").value("수정된 소개")); + } + + @Test + @DisplayName("판매자 프로필을 삭제하면 204 상태를 반환한다") + void deleteProfile() throws Exception { + mockMvc.perform(delete("/seller-profiles/me")) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + verify(sellerProfileService).delete(1L); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java new file mode 100644 index 0000000..07c9113 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java @@ -0,0 +1,64 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SellerProfileCreateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("올바른 판매자 프로필 요청은 검증을 통과한다") + void acceptValidRequest() { + SellerProfileCreateRequest request = new SellerProfileCreateRequest( + "판매자 소개", + "https://example.com/profile.png", + "https://instagram.com/example", + "https://youtube.com/@example" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("소개는 2000자를 초과할 수 없다") + void rejectTooLongDescription() { + SellerProfileCreateRequest request = new SellerProfileCreateRequest( + "a".repeat(2001), null, null, null + ); + + assertViolationCount(request, "description", 1); + } + + @Test + @DisplayName("URL은 2048자를 초과할 수 없다") + void rejectTooLongUrl() { + SellerProfileCreateRequest request = new SellerProfileCreateRequest( + null, "a".repeat(2049), null, null + ); + + assertViolationCount(request, "imageUrl", 1); + } + + private void assertViolationCount( + SellerProfileCreateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java new file mode 100644 index 0000000..c312925 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java @@ -0,0 +1,64 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SellerProfileUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("올바른 판매자 프로필 요청은 검증을 통과한다") + void acceptValidRequest() { + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + "판매자 소개", + "https://example.com/profile.png", + "https://instagram.com/example", + "https://youtube.com/@example" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("소개는 2000자를 초과할 수 없다") + void rejectTooLongDescription() { + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + "a".repeat(2001), null, null, null + ); + + assertViolationCount(request, "description", 1); + } + + @Test + @DisplayName("URL은 2048자를 초과할 수 없다") + void rejectTooLongUrl() { + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + null, "a".repeat(2049), null, null + ); + + assertViolationCount(request, "imageUrl", 1); + } + + private void assertViolationCount( + SellerProfileUpdateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java b/src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java new file mode 100644 index 0000000..74b8027 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java @@ -0,0 +1,56 @@ +package com.dropit.sellerprofile.entity; + +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SellerProfileTest { + + private final User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + + @Test + @DisplayName("판매자 프로필을 생성하면 입력한 정보가 저장된다") + void createSellerProfile() { + SellerProfile sellerProfile = new SellerProfile( + seller, + "판매자 소개", + "https://example.com/profile.png", + "https://instagram.com/example", + "https://youtube.com/@example" + ); + + assertEquals(seller, sellerProfile.getUser()); + assertEquals("판매자 소개", sellerProfile.getDescription()); + assertEquals("https://example.com/profile.png", sellerProfile.getImageUrl()); + assertEquals("https://instagram.com/example", sellerProfile.getInstagramUrl()); + assertEquals("https://youtube.com/@example", sellerProfile.getYoutubeUrl()); + } + + @Test + @DisplayName("판매자 프로필 정보를 수정할 수 있다") + void updateSellerProfile() { + SellerProfile sellerProfile = new SellerProfile( + seller, "기존 소개", null, null, null + ); + + sellerProfile.update( + "수정된 소개", + "https://example.com/updated.png", + "https://instagram.com/updated", + "https://youtube.com/@updated" + ); + + assertEquals("수정된 소개", sellerProfile.getDescription()); + assertEquals("https://example.com/updated.png", sellerProfile.getImageUrl()); + assertEquals("https://instagram.com/updated", sellerProfile.getInstagramUrl()); + assertEquals("https://youtube.com/@updated", sellerProfile.getYoutubeUrl()); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java b/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java new file mode 100644 index 0000000..4671913 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java @@ -0,0 +1,191 @@ +package com.dropit.sellerprofile.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.entity.SellerProfile; +import com.dropit.sellerprofile.exception.SellerProfileErrorCode; +import com.dropit.sellerprofile.repository.SellerProfileRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.function.Executable; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SellerProfileServiceTest { + + @Mock SellerProfileRepository sellerProfileRepository; + @Mock UserRepository userRepository; + @InjectMocks SellerProfileService sellerProfileService; + + @Test + @DisplayName("판매자는 판매자 프로필을 등록할 수 있다") + void createSellerProfile() { + Long userId = 1L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", userId); + SellerProfileCreateRequest request = createRequest(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(seller)); + when(sellerProfileRepository.existsByUser_Id(userId)).thenReturn(false); + when(sellerProfileRepository.save(any(SellerProfile.class))).thenAnswer(invocation -> { + SellerProfile profile = invocation.getArgument(0); + ReflectionTestUtils.setField(profile, "id", 100L); + return profile; + }); + + SellerProfileResponse response = sellerProfileService.create(userId, request); + + assertEquals(100L, response.id()); + assertEquals(userId, response.userId()); + assertEquals("판매자 소개", response.description()); + } + + @Test + @DisplayName("존재하지 않는 사용자는 판매자 프로필을 등록할 수 없다") + void rejectMissingUser() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertServiceException( + SellerProfileErrorCode.USER_NOT_FOUND, + () -> sellerProfileService.create(1L, createRequest()) + ); + + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("일반 사용자는 판매자 프로필을 등록할 수 없다") + void rejectNonSeller() { + User user = createUser(UserRole.USER); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertServiceException( + SellerProfileErrorCode.SELLER_ROLE_REQUIRED, + () -> sellerProfileService.create(1L, createRequest()) + ); + + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("판매자 프로필은 중복 등록할 수 없다") + void rejectDuplicateProfile() { + User seller = createUser(UserRole.SELLER); + when(userRepository.findById(1L)).thenReturn(Optional.of(seller)); + when(sellerProfileRepository.existsByUser_Id(1L)).thenReturn(true); + + assertServiceException( + SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS, + () -> sellerProfileService.create(1L, createRequest()) + ); + + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("내 판매자 프로필을 조회할 수 있다") + void getMine() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = createProfile(seller); + ReflectionTestUtils.setField(profile, "id", 100L); + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.of(profile)); + + SellerProfileResponse response = sellerProfileService.getMine(1L); + + assertEquals(100L, response.id()); + assertEquals(1L, response.userId()); + } + + @Test + @DisplayName("존재하지 않는 판매자 프로필은 조회할 수 없다") + void rejectMissingProfile() { + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.empty()); + + assertServiceException( + SellerProfileErrorCode.SELLER_PROFILE_NOT_FOUND, + () -> sellerProfileService.getMine(1L) + ); + } + + @Test + @DisplayName("판매자 프로필 ID로 조회할 수 있다") + void getById() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = createProfile(seller); + ReflectionTestUtils.setField(profile, "id", 100L); + when(sellerProfileRepository.findById(100L)).thenReturn(Optional.of(profile)); + + SellerProfileResponse response = sellerProfileService.getById(100L); + + assertEquals(100L, response.id()); + assertEquals(1L, response.userId()); + } + + @Test + @DisplayName("판매자 프로필을 수정할 수 있다") + void updateSellerProfile() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = createProfile(seller); + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.of(profile)); + + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + "수정된 소개", "updated-image", "updated-instagram", "updated-youtube" + ); + + SellerProfileResponse response = sellerProfileService.update(1L, request); + + assertEquals("수정된 소개", profile.getDescription()); + assertEquals("수정된 소개", response.description()); + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("판매자 프로필을 삭제할 수 있다") + void deleteSellerProfile() { + User seller = createUser(UserRole.SELLER); + SellerProfile profile = createProfile(seller); + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.of(profile)); + + sellerProfileService.delete(1L); + + verify(sellerProfileRepository).delete(profile); + } + + private SellerProfileCreateRequest createRequest() { + return new SellerProfileCreateRequest("판매자 소개", null, null, null); + } + + private SellerProfile createProfile(User user) { + return new SellerProfile(user, "판매자 소개", null, null, null); + } + + private User createUser(UserRole role) { + return new User("seller@example.com", "encoded-password", "seller", role); + } + + private void assertServiceException(SellerProfileErrorCode errorCode, Executable executable) { + ServiceException exception = assertThrows(ServiceException.class, executable); + assertEquals(errorCode, exception.getErrorCode()); + } +}