diff --git a/.github/workflows/other.yml b/.github/workflows/other.yml index 9caded3d..6a515688 100644 --- a/.github/workflows/other.yml +++ b/.github/workflows/other.yml @@ -15,33 +15,32 @@ jobs: steps: - uses: actions/checkout@v4 - # - name: Set up JDK 21 - # uses: actions/setup-java@v3 - # with: - # java-version: '21' - # distribution: 'temurin' - # cache: gradle + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle - # - name: Grant execute permission for gradlew - # run: | - # cd techeerzip - # chmod +x gradlew + - name: Grant execute permission for gradlew + run: | + cd techeerzip + chmod +x gradlew - # - name: Run tests with coverage - # run: | - # cd techeerzip - # ./gradlew test jacocoTestReport + - name: Run tests with coverage + run: | + cd techeerzip + ./gradlew test jacocoTestReport - # - name: Publish Jacoco coverage report - # uses: PavanMudigonda/jacoco-reporter@v5.0 - # with: - # coverage_results_path: techeerzip/build/reports/jacoco/test/jacocoTestReport.xml - # coverage_paths: techeerzip/build/reports/jacoco/test/html/index.html - # github_token: ${{ secrets.GITHUB_TOKEN }} - # minimum_coverage: 30 - # fail_below_threshold: false - # output_level: inline - # coverage_report_name: JaCoCo Coverage + - name: Publish Jacoco coverage report + uses: Madrapps/jacoco-report@v1.7.2 + with: + paths: techeerzip/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 30 + min-coverage-changed-files: 30 + title: Code Coverage + update-comment: true - name: Show build number run: | @@ -76,4 +75,4 @@ jobs: docker buildx build \ --platform linux/amd64 \ --push \ - -t ${{ secrets.DOCKER_HUB_USERNAME }}/techeerism-spring:$DOCKER_IMAGE_TAG . \ No newline at end of file + -t ${{ secrets.DOCKER_HUB_USERNAME }}/techeerism-spring:$DOCKER_IMAGE_TAG . diff --git a/.rules/.cursorrules.mdc b/.rules/.cursorrules.mdc new file mode 100644 index 00000000..6397eab0 --- /dev/null +++ b/.rules/.cursorrules.mdc @@ -0,0 +1,199 @@ +# Spring Boot Development Rules for Cursor AI + +## 역할 +당신은 Spring Boot 전문 개발자입니다. 클린 코드와 Best Practice를 준수하며, 유지보수 가능한 코드를 작성합니다. + +## 전역 예외 처리 (Global Exception Handling) + +### 원칙 +- 모든 예외는 `@RestControllerAdvice`를 사용한 전역 예외 핸들러에서 처리 +- 비즈니스 예외는 커스텀 예외 클래스를 생성하여 사용 +- 예외 발생 시 일관된 형식의 에러 응답 반환 +- 예외 처리 시 적절한 HTTP 상태 코드 사용 + +### 구현 요구사항 +- `GlobalExceptionHandler` 클래스 생성 (`@RestControllerAdvice` 사용) +- 커스텀 예외: `CustomException` 또는 도메인별 예외 클래스 (예: `UserNotFoundException`) +- 에러 응답 DTO: `ErrorResponse` 클래스 (code, message, timestamp, errors 필드 포함) +- 주요 예외 처리: + - `CustomException`: 비즈니스 로직 예외 + - `MethodArgumentNotValidException`: 유효성 검증 실패 + - `HttpRequestMethodNotSupportedException`: 잘못된 HTTP 메서드 + - `Exception`: 예상치 못한 예외 + +## 에러 코드 (Error Code) + +### 원칙 +- 모든 에러는 고유한 에러 코드를 가짐 +- 에러 코드는 Enum으로 관리 +- 에러 코드 네이밍: `도메인_상황` (예: `USER_NOT_FOUND`, `INVALID_INPUT`) +- HTTP 상태 코드와 에러 메시지를 함께 관리 + +### 구조 +``` +ErrorCode Enum 필드: +- HttpStatus status (HTTP 상태 코드) +- String code (에러 코드, 예: "U001") +- String message (에러 메시지) + +카테고리별 에러 코드: +- Common (C001~): 공통 에러 (잘못된 입력, 서버 오류 등) +- User (U001~): 사용자 관련 +- Auth (A001~): 인증/인가 관련 +- (도메인별로 추가) +``` + +## Swagger (API 문서화) + +### 원칙 +- 모든 API는 Swagger로 문서화 +- 성공/실패 케이스 모두 명시 +- 예외 상황별 응답 예시 작성 + +### 어노테이션 사용 +- 클래스: `@Tag(name, description)` +- 메서드: `@Operation(summary, description)` +- 응답: `@ApiResponses` (성공 200, 실패 4xx/5xx 모두 명시) +- 파라미터: `@Parameter(description, required)` +- 요청/응답 모델: `@Schema(description, example)` + +### 실패 테스트 필수 +모든 API에 대해 다음 케이스 문서화: +- 성공 케이스 (200, 201) +- 잘못된 입력 (400) +- 인증 실패 (401) +- 권한 없음 (403) +- 리소스 없음 (404) +- 서버 오류 (500) + +## 클린 코드 (Clean Code) + +### SRP (Single Responsibility Principle) +- 하나의 클래스/메서드는 하나의 책임만 가짐 +- Controller: 요청/응답 처리만 +- Service: 비즈니스 로직만 +- Repository: 데이터 접근만 +- Validator: 유효성 검증만 +- 책임이 여러 개면 클래스/메서드 분리 + +### 네이밍 규칙 +- 클래스명: PascalCase, 명사 (예: UserService, OrderController) +- 메서드명: camelCase, 동사+명사 (예: createUser, findById) +- 변수명: camelCase, 명확한 의미 (예: userId, userEmail) +- 상수명: UPPER_SNAKE_CASE +- Boolean: is/has/can으로 시작 (예: isActive, hasPermission) + +### 메서드 작성 +- 메서드는 20줄 이내로 작성 +- 한 가지 작업만 수행 +- Early Return 패턴 사용 +- 깊은 중첩 지양 (최대 2depth) + +### 주석 +- 코드로 설명 가능한 부분은 주석 작성 금지 +- Why는 주석으로, What은 코드로 +- 복잡한 비즈니스 로직만 주석 작성 + +## 패키지 구조 + +### 도메인 중심 구조 (권장) +``` +src/main/java/com/example/project/ +├── domain/ +│ ├── user/ +│ │ ├── controller/ +│ │ ├── service/ +│ │ ├── repository/ +│ │ ├── entity/ +│ │ └── dto/ +│ ├── order/ +│ └── ... +├── global/ +│ ├── config/ +│ ├── exception/ +│ ├── common/ +│ └── util/ +└── Application.java +``` + +### 계층별 책임 +- **controller**: API 엔드포인트, 요청 검증 +- **service**: 비즈니스 로직, 트랜잭션 관리 +- **repository**: 데이터 접근 +- **entity**: JPA 엔티티 +- **dto**: Request/Response 객체 +- **global/exception**: 전역 예외 처리 +- **global/config**: 설정 클래스 +- **global/common**: 공통 상수, Enum + +## 환경 변수 (.env) + +### 원칙 +- 민감 정보는 절대 코드에 하드코딩 금지 +- `.env` 파일 또는 `application-{profile}.yml` 사용 +- `.gitignore`에 환경 파일 추가 + +### 관리 대상 +- 데이터베이스 접속 정보 +- API 키 (외부 서비스) +- JWT Secret Key +- 이메일 SMTP 정보 +- 포트 번호 +- 프로필별 설정값 + +### 사용 방법 +```yaml +# application.yml +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} +``` + +## 코드 작성 시 필수 체크리스트 + +### API 개발 시 +- [ ] 전역 예외 처리 적용 +- [ ] 에러 코드 정의 +- [ ] Swagger 문서화 (성공/실패 케이스) +- [ ] DTO Validation (`@Valid`, `@NotNull` 등) +- [ ] 적절한 HTTP 상태 코드 반환 + +### 클래스 작성 시 +- [ ] SRP 준수 (단일 책임) +- [ ] 적절한 패키지 위치 +- [ ] 명확한 네이밍 +- [ ] 불필요한 주석 제거 +- [ ] 매직 넘버/문자열 상수화 + +### 보안 +- [ ] 민감 정보 환경 변수 처리 +- [ ] SQL Injection 방어 (JPA 사용) +- [ ] XSS 방어 +- [ ] 적절한 권한 검증 + +## 코드 리뷰 기준 +코드를 제안할 때 다음을 자동으로 검토하고 개선점 제시: +1. 예외 처리가 적절한가? +2. SRP를 위반하지 않는가? +3. 네이밍이 명확한가? +4. 불필요한 코드는 없는가? +5. 보안 이슈는 없는가? + +--- + +## 코드 생성 시 기본 템플릿 + +새로운 도메인 기능 추가 시 다음 순서로 생성: +1. Entity 정의 +2. Repository 인터페이스 +3. DTO (Request/Response) +4. Service (인터페이스 + 구현체) +5. Controller +6. 커스텀 예외 클래스 +7. ErrorCode 추가 +8. Swagger 문서화 +9. 테스트 코드 + +이 규칙을 항상 준수하며 코드를 작성하고, 규칙 위반 시 개선 방안을 제시하세요. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 02627d3b..b4109a60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:21-slim AS builder +FROM eclipse-temurin:21-jdk AS builder WORKDIR /app RUN apt-get update && apt-get install -y findutils wget COPY ./techeerzip/ /app/ @@ -8,7 +8,7 @@ RUN ./gradlew clean spotlessApply bootJar --no-daemon -x test # OpenTelemetry Java Agent 다운로드 RUN wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.5.0/opentelemetry-javaagent.jar -FROM openjdk:21-slim +FROM eclipse-temurin:21-jre RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg \ && rm -rf /var/lib/apt/lists/* diff --git a/techeerzip/build.gradle b/techeerzip/build.gradle index b80244a9..2eaf6ee9 100644 --- a/techeerzip/build.gradle +++ b/techeerzip/build.gradle @@ -37,9 +37,6 @@ repositories { } dependencies { - // test H2 - testImplementation 'com.h2database:h2:2.2.224' - implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -70,13 +67,19 @@ dependencies { implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'org.projectlombok:lombok:1.18.30' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' - compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' + + // test + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'com.h2database:h2:2.2.224' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' // Spring Boot 3.1+ 부터 제공하는 편리한 기능 + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // SpringDoc OpenAPI (테스트 용도) @@ -102,7 +105,7 @@ dependencies { // Pyroscope implementation 'io.pyroscope:agent:2.1.2' - + // GraphQL 클라이언트 (WebFlux) implementation 'org.springframework.boot:spring-boot-starter-webflux' @@ -172,55 +175,55 @@ spotless { // Spotless가 compileJava 이후에 실행되도록 설정 spotlessJava.dependsOn compileJava -//jacoco { -// toolVersion = "0.8.11" -//} -// -//// 공통 exclusion 패턴 정의 -//def jacocoExcluded = [ -// '**/Q*.class', // QueryDSL generated -// '**/*Builder*', // Lombok builder -// '**/dto/**', // DTO -// '**/exception/**', // Exception -// '**/config/**', // Config -// '**/infra/**', // Infra (S3, MQ 등) -// '**/global/**' // Global 패키지 -//] - -// // jacocoReport -// tasks.named('jacocoTestReport') { -// dependsOn test -// reports { -// xml.required = true -// html.required = true -// } -// classDirectories.setFrom(files( -// classDirectories.files.collect { fileTree(dir: it, exclude: jacocoExcluded) } -// )) -// } - -// // jacocoCoverage 확인 -// tasks.named('jacocoTestCoverageVerification') { -// classDirectories.setFrom(files( -// classDirectories.files.collect { fileTree(dir: it, exclude: jacocoExcluded) } -// )) - -// violationRules { -// rule { -// element = 'CLASS' -// limit { -// counter = 'LINE' -// value = 'COVEREDRATIO' -// minimum = 0.00 -// } -// limit { -// counter = 'BRANCH' -// value = 'COVEREDRATIO' -// minimum = 0.00 -// } -// } -// } -// } +jacoco { + toolVersion = "0.8.11" +} + +// 공통 exclusion 패턴 정의 +def jacocoExcluded = [ + '**/Q*.class', // QueryDSL generated + '**/*Builder*', // Lombok builder + '**/dto/**', // DTO + '**/exception/**', // Exception + '**/config/**', // Config + '**/infra/**', // Infra (S3, MQ 등) + '**/global/**' // Global 패키지 +] + + // jacocoReport + tasks.named('jacocoTestReport', JacocoReport) { + dependsOn test + reports { + xml.required = true + html.required = true + } + classDirectories.setFrom(files( + classDirectories.files.collect { fileTree(dir: it, exclude: jacocoExcluded) } + )) + } + + // jacocoCoverage 확인 + tasks.named('jacocoTestCoverageVerification', JacocoCoverageVerification) { + classDirectories.setFrom(files( + classDirectories.files.collect { fileTree(dir: it, exclude: jacocoExcluded) } + )) + + violationRules { + rule { + element = 'CLASS' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.00 + } + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.00 + } + } + } + } // 테스트 task 마무리 설정 tasks.test { diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/CustomUserPrincipal.java b/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/CustomUserPrincipal.java index 99e0021a..4953ddcc 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/CustomUserPrincipal.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/CustomUserPrincipal.java @@ -18,14 +18,17 @@ public class CustomUserPrincipal implements UserDetails { private final Long userId; private final String email; private final String password; + private final Integer bootcampYear; private final RoleType role; private final Collection authorities; - public CustomUserPrincipal(Long userId, String email, String password, RoleType role) { + public CustomUserPrincipal( + Long userId, String email, String password, Integer bootcampYear, RoleType role) { this.userId = userId; this.email = email; this.password = password; this.role = role; + this.bootcampYear = bootcampYear; this.authorities = List.of(new SimpleGrantedAuthority(role.getRoleName().toUpperCase())); } diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/JwtTokenProvider.java b/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/JwtTokenProvider.java index b5f25e2c..6f913e4a 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/JwtTokenProvider.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/auth/jwt/JwtTokenProvider.java @@ -31,6 +31,8 @@ @RequiredArgsConstructor public class JwtTokenProvider { private static final String ROLE_KEY = "role"; + private static final String USER_ID = "userId"; + private static final String BOOTCAMP_YEAR = "bootcampYear"; private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000L * 60 * 60; // 1시간 private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 24 * 7; // 7일 @@ -53,14 +55,16 @@ public TokenPair generateTokenPair(Authentication authentication) { CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal(); Long userId = principal.getUserId(); String role = principal.getRole().name(); + Integer bootcampYear = principal.getBootcampYear(); long now = new Date().getTime(); String accessToken = Jwts.builder() .setSubject(authentication.getName()) - .claim("userId", userId) + .claim(USER_ID, userId) .claim(ROLE_KEY, role) + .claim(BOOTCAMP_YEAR, bootcampYear) .setIssuedAt(new Date()) .setExpiration(new Date(now + ACCESS_TOKEN_EXPIRE_TIME)) .signWith(key, SignatureAlgorithm.HS512) @@ -69,8 +73,9 @@ public TokenPair generateTokenPair(Authentication authentication) { String refreshToken = Jwts.builder() .setSubject(authentication.getName()) - .claim("userId", userId) + .claim(USER_ID, userId) .claim(ROLE_KEY, role) + .claim(BOOTCAMP_YEAR, bootcampYear) .setIssuedAt(new Date()) .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME)) .signWith(key, SignatureAlgorithm.HS512) @@ -84,14 +89,16 @@ public Authentication getAuthentication(String token) { Claims claims = parseClaims(token); String email = claims.getSubject(); - Long userId = claims.get("userId", Long.class); + Long userId = claims.get(USER_ID, Long.class); String roleName = claims.get(ROLE_KEY, String.class); + Integer bootcampYear = claims.get(BOOTCAMP_YEAR, Integer.class); RoleType role = RoleType.valueOf(roleName); GrantedAuthority authority = new SimpleGrantedAuthority(role.getRoleName().toUpperCase()); List authorities = List.of(authority); - CustomUserPrincipal principal = new CustomUserPrincipal(userId, email, "", role); + CustomUserPrincipal principal = + new CustomUserPrincipal(userId, email, "", bootcampYear, role); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/auth/service/CustomUserDetailsService.java b/techeerzip/src/main/java/backend/techeerzip/domain/auth/service/CustomUserDetailsService.java index 2d8513e8..72b3ed38 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/auth/service/CustomUserDetailsService.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/auth/service/CustomUserDetailsService.java @@ -68,6 +68,7 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep user.getId(), user.getEmail(), user.getPassword(), + user.getBootcampYear(), RoleType.valueOf(roleName.toUpperCase())); } } diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/controller/BootcampController.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/controller/BootcampController.java index d536f7b3..8fad3de9 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/controller/BootcampController.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/controller/BootcampController.java @@ -43,10 +43,11 @@ public class BootcampController implements BootcampSwagger { private final BootcampFacadeService bootcampFacadeService; @Override + @PreAuthorize("hasPermission(null, 'BOOTCAMP', 'CREATE')") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createBootcamp( @RequestPart("image") @Valid MultipartFile imageFile, - @RequestPart("request") BootcampCreateRequest request) { + @RequestPart("request") @Valid BootcampCreateRequest request) { BootcampResponse response = bootcampFacadeService.createBootcamp(imageFile, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -84,6 +85,7 @@ public ResponseEntity getBootcamp(@PathVariable Long bootcampI } @Override + @PreAuthorize("hasPermission(#bootcampId, 'Bootcamp', 'UPDATE')") @PutMapping(value = "/{bootcampId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateBootcamp( @PathVariable Long bootcampId, @@ -95,6 +97,7 @@ public ResponseEntity updateBootcamp( } @Override + @PreAuthorize("hasPermission(#bootcampId, 'Bootcamp', 'DELETE')") @DeleteMapping("/{bootcampId}") public ResponseEntity deleteBootcamp(@PathVariable Long bootcampId) { bootcampService.deleteBootcamp(bootcampId); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampCreateRequest.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampCreateRequest.java index 4c687ad6..69ed4ef1 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampCreateRequest.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampCreateRequest.java @@ -2,8 +2,14 @@ import java.util.List; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + import backend.techeerzip.domain.bootcampMember.entity.BootcampPosition; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -33,41 +39,52 @@ } """) @Getter +@Builder @NoArgsConstructor +@AllArgsConstructor public class BootcampCreateRequest { @Schema(example = "Next-Page", description = "프로젝트 이름") + @NotBlank(message = "프로젝트 이름은 필수입니다.") private String name; @Schema(example = "A", description = "팀명") + @NotBlank(message = "팀명은 필수입니다.") private String team; - @Schema(example = "상상을 현실로, 손끝에서 펼쳐지는 우리만의 세계", description = "프로젝트 설명") + @Schema(example = "상상을 현실로...", description = "프로젝트 설명") private String projectExplain; - @Schema(example = "https://github.com/bootcamp/project", description = "GitHub URL") - private String githubUrl; + @Schema(example = "https://github.com/...", description = "GitHub URL") + private String githubUrl; // 필수 아님 (선택 사항) - @Schema(example = "https://medium.com/@bootcamp", description = "Medium URL") + @Schema(example = "https://medium.com/...", description = "Medium URL") private String mediumUrl; @Schema(example = "https://bootcamp.com", description = "웹사이트 URL") private String webUrl; @Schema(description = "부트캠프 멤버 목록") + @NotNull(message = "멤버 목록은 필수입니다.") + @Valid private List members; @Schema(description = "부트캠프 멤버 요청 DTO") @Getter @NoArgsConstructor + @AllArgsConstructor + @Builder public static class BootcampMemberRequest { @Schema(example = "1", description = "사용자 ID") + @NotNull(message = "사용자 ID는 필수입니다.") private Long userId; @Schema(example = "BE", description = "포지션 (BE, FE, DEV)") + @NotNull(message = "포지션은 필수입니다.") private BootcampPosition position; @Schema(example = "true", description = "리더 여부") + @NotNull(message = "리더 여부는 필수입니다.") private Boolean isLeader; } } diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampRankUpdateRequest.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampRankUpdateRequest.java index 46604511..6b18e9d8 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampRankUpdateRequest.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampRankUpdateRequest.java @@ -1,9 +1,11 @@ package backend.techeerzip.domain.bootcamp.dto.request; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter +@AllArgsConstructor @NoArgsConstructor public class BootcampRankUpdateRequest { private Integer rank; diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampVisibilityUpdateRequest.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampVisibilityUpdateRequest.java index a8d19745..5e909bdc 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampVisibilityUpdateRequest.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/request/BootcampVisibilityUpdateRequest.java @@ -1,9 +1,11 @@ package backend.techeerzip.domain.bootcamp.dto.request; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter +@AllArgsConstructor @NoArgsConstructor public class BootcampVisibilityUpdateRequest { private Boolean isOpen; diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/response/BootcampListResponse.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/response/BootcampListResponse.java index 98510e2d..f7307097 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/response/BootcampListResponse.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/dto/response/BootcampListResponse.java @@ -20,6 +20,8 @@ public class BootcampListResponse { @Builder public static class BootcampListItem { private Long id; + private String name; + private String projectExplain; private Integer year; private String imageUrl; private Integer rank; diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/entity/BootcampGeneration.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/entity/BootcampGeneration.java index aa1a6f7e..ab4e9dba 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/entity/BootcampGeneration.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/entity/BootcampGeneration.java @@ -4,6 +4,7 @@ import java.time.Month; import backend.techeerzip.domain.bootcamp.exception.InvalidBootcampCreationPeriodException; +import lombok.Getter; /** * 부트캠프 세대 계산 및 기간 관리 @@ -12,6 +13,7 @@ * *

세대 계산 예시: - 2025년 6-8월 (하계) = 10기 - 2025년 12월-2026년 2월 (동계) = 11기 - 2026년 6-8월 (하계) = 12기 */ +@Getter public enum BootcampGeneration { SUMMER("하계"), WINTER("동계"); @@ -47,9 +49,9 @@ private static int getEffectiveYear(LocalDate date, BootcampGeneration period) { } /** 세대 번호 계산 기준: 2025 하계 = 10기 */ - private static int calculateGeneration(LocalDate currentDate) { - BootcampGeneration currentPeriod = getCurrentPeriod(currentDate); - int effectiveYear = getEffectiveYear(currentDate, currentPeriod); + public static int calculateBootcampGeneration(LocalDate localDate) { + BootcampGeneration currentPeriod = getCurrentPeriod(localDate); + int effectiveYear = getEffectiveYear(localDate, currentPeriod); int yearDiff = effectiveYear - BASE_YEAR; @@ -59,20 +61,13 @@ private static int calculateGeneration(LocalDate currentDate) { return BASE_GENERATION + termDiff; } - public static int calculateCurrentGeneration() { - return calculateGeneration(LocalDate.now()); - } - /** 부트캠프 생성 가능 기간인지 검증 하계/동계 기간에만 생성 가능 */ - public static void validateCreationPeriod() { - LocalDate now = LocalDate.now(); - BootcampGeneration currentPeriod = getCurrentPeriod(now); - - Month month = now.getMonth(); + public static void validateCreationPeriod(LocalDate now) { + int monthVal = now.getMonth().getValue(); boolean isValidPeriod = - (month.getValue() >= 6 && month.getValue() <= 8) + (monthVal >= 6 && monthVal <= 8) || // 하계: 6-8월 - (month.getValue() >= 12 || month.getValue() <= 2); // 동계: 12-2월 + (monthVal == 12 || monthVal <= 2); // 동계: 12-2월 if (!isValidPeriod) { throw new InvalidBootcampCreationPeriodException(); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/exception/BootcampNotParticipantException.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/exception/BootcampNotParticipantException.java new file mode 100644 index 00000000..0aa368f0 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/exception/BootcampNotParticipantException.java @@ -0,0 +1,11 @@ +package backend.techeerzip.domain.bootcamp.exception; + +import backend.techeerzip.global.exception.BusinessException; +import backend.techeerzip.global.exception.ErrorCode; + +public class BootcampNotParticipantException extends BusinessException { + + public BootcampNotParticipantException() { + super(ErrorCode.BOOTCAMP_NOT_PARTICIPANT); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/mapper/BootcampMapper.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/mapper/BootcampMapper.java index 32aaf7fb..06a7946a 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/mapper/BootcampMapper.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/mapper/BootcampMapper.java @@ -1,11 +1,10 @@ package backend.techeerzip.domain.bootcamp.mapper; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import org.springframework.stereotype.Component; - import backend.techeerzip.domain.bootcamp.dto.request.BootcampCreateRequest; import backend.techeerzip.domain.bootcamp.dto.response.BootcampListResponse; import backend.techeerzip.domain.bootcamp.dto.response.BootcampResponse; @@ -14,16 +13,17 @@ import backend.techeerzip.domain.bootcampMember.dto.response.BootcampMemberResponse; import backend.techeerzip.domain.bootcampMember.entity.BootcampMember; import backend.techeerzip.domain.user.entity.User; -import backend.techeerzip.domain.user.exception.UserNotFoundException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; -@Component +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class BootcampMapper { - public Bootcamp toEntity(BootcampCreateRequest request, String imageUrl) { + public static Bootcamp toEntity(BootcampCreateRequest request, String imageUrl) { return Bootcamp.builder() .name(request.getName()) .team(request.getTeam()) - .year(BootcampGeneration.calculateCurrentGeneration()) + .year(BootcampGeneration.calculateBootcampGeneration(LocalDate.now())) .projectExplain(request.getProjectExplain()) .githubUrl(request.getGithubUrl()) .mediumUrl(request.getMediumUrl()) @@ -34,11 +34,9 @@ public Bootcamp toEntity(BootcampCreateRequest request, String imageUrl) { .build(); } - public BootcampResponse toResponse(Bootcamp bootcamp) { + public static BootcampResponse toResponse(Bootcamp bootcamp) { List memberResponses = - bootcamp.getMembers().stream() - .map(this::toMemberResponse) - .collect(Collectors.toList()); + bootcamp.getMembers().stream().map(BootcampMapper::toMemberResponse).toList(); return BootcampResponse.builder() .id(bootcamp.getId()) @@ -59,7 +57,7 @@ public BootcampResponse toResponse(Bootcamp bootcamp) { .build(); } - private BootcampMemberResponse toMemberResponse(BootcampMember member) { + private static BootcampMemberResponse toMemberResponse(BootcampMember member) { return BootcampMemberResponse.builder() .userId(member.getUser().getId()) .name(member.getUser().getName()) @@ -68,10 +66,10 @@ private BootcampMemberResponse toMemberResponse(BootcampMember member) { .build(); } - public BootcampListResponse toListResponse( + public static BootcampListResponse toListResponse( List bootcamps, Long nextCursor, Integer nextCursorRank, Boolean hasNext) { List items = - bootcamps.stream().map(this::toListItem).collect(Collectors.toList()); + bootcamps.stream().map(BootcampMapper::toListItem).collect(Collectors.toList()); return BootcampListResponse.builder() .data(items) @@ -81,14 +79,10 @@ public BootcampListResponse toListResponse( .build(); } - public BootcampListResponse toListResponseWithUserGeneration( - List bootcamps, - Long nextCursor, - Integer nextCursorRank, - Boolean hasNext, - Integer userBootcampYear) { + public static BootcampListResponse toListResponseWithUserGeneration( + List bootcamps, Long nextCursor, Integer nextCursorRank, Boolean hasNext) { List items = - bootcamps.stream().map(this::toListItem).collect(Collectors.toList()); + bootcamps.stream().map(BootcampMapper::toListItem).toList(); return BootcampListResponse.builder() .data(items) @@ -98,32 +92,32 @@ public BootcampListResponse toListResponseWithUserGeneration( .build(); } - private BootcampListResponse.BootcampListItem toListItem(Bootcamp bootcamp) { + private static BootcampListResponse.BootcampListItem toListItem(Bootcamp bootcamp) { return BootcampListResponse.BootcampListItem.builder() .id(bootcamp.getId()) + .name(bootcamp.getName()) + .projectExplain(bootcamp.getProjectExplain()) .year(bootcamp.getYear()) .imageUrl(bootcamp.getImageUrl()) .rank(bootcamp.getRank()) .build(); } - public List toMemberEntities( + public static List toMemberEntities( Bootcamp bootcamp, List memberRequests, Map userMap) { return memberRequests.stream() .map( - memberRequest -> { - User user = userMap.get(memberRequest.getUserId()); - if (user == null) { - throw new UserNotFoundException(); - } - return toMemberEntity(bootcamp, memberRequest, user); - }) - .collect(Collectors.toList()); + memberRequest -> + toMemberEntity( + bootcamp, + memberRequest, + userMap.get(memberRequest.getUserId()))) + .toList(); } - public BootcampMember toMemberEntity( + public static BootcampMember toMemberEntity( Bootcamp bootcamp, BootcampCreateRequest.BootcampMemberRequest memberRequest, User user) { diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/repository/BootcampMemberRepository.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/repository/BootcampMemberRepository.java new file mode 100644 index 00000000..029021d1 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/repository/BootcampMemberRepository.java @@ -0,0 +1,12 @@ +package backend.techeerzip.domain.bootcamp.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import backend.techeerzip.domain.bootcampMember.entity.BootcampMember; + +@Repository +public interface BootcampMemberRepository extends JpaRepository { + + boolean existsByBootcampIdAndUserIdAndIsDeletedFalse(Long bootcampId, Long userId); +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImpl.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImpl.java index c130f398..dcc1ee7c 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImpl.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImpl.java @@ -1,6 +1,8 @@ package backend.techeerzip.domain.bootcamp.service; import java.nio.file.Path; +import java.time.Clock; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -22,10 +24,11 @@ public class BootcampFacadeServiceImpl implements BootcampFacadeService { private final BootcampService bootcampService; private final CustomLogger logger; private final FileConverter fileConverter; + private final Clock clock; @Override public BootcampResponse createBootcamp(MultipartFile imageFile, BootcampCreateRequest request) { - BootcampGeneration.validateCreationPeriod(); + BootcampGeneration.validateCreationPeriod(LocalDate.now(clock)); logger.info("Bootcamp Facade: Create - 기간 검증 완료, S3 업로드 시작"); final Path imagePath = fileConverter.prepareForUpload(imageFile); final List imageUrl = new ArrayList<>(); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampService.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampService.java index 7dfe4580..f5ba6939 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampService.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/service/BootcampService.java @@ -1,8 +1,9 @@ package backend.techeerzip.domain.bootcamp.service; +import java.time.Clock; +import java.time.LocalDate; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import jakarta.validation.constraints.NotNull; @@ -21,42 +22,59 @@ import backend.techeerzip.domain.bootcamp.entity.Bootcamp; import backend.techeerzip.domain.bootcamp.entity.BootcampGeneration; import backend.techeerzip.domain.bootcamp.exception.BootcampNotFoundException; +import backend.techeerzip.domain.bootcamp.exception.BootcampNotParticipantException; import backend.techeerzip.domain.bootcamp.mapper.BootcampMapper; +import backend.techeerzip.domain.bootcamp.repository.BootcampMemberRepository; import backend.techeerzip.domain.bootcamp.repository.BootcampRepository; import backend.techeerzip.domain.bootcampMember.entity.BootcampMember; import backend.techeerzip.domain.user.entity.User; import backend.techeerzip.domain.user.service.UserService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service -@Transactional +@Transactional(readOnly = true) @Validated @RequiredArgsConstructor public class BootcampService { private final BootcampRepository bootcampRepository; - private final BootcampMapper bootcampMapper; + private final BootcampMemberRepository bootcampMemberRepository; private final UserService userService; + private final Clock clock; @Transactional public BootcampResponse createBootcamp( - BootcampCreateRequest bootcampCreateRequest, String imageUrl) { + @NotNull BootcampCreateRequest bootcampCreateRequest, String imageUrl) { List userIds = bootcampCreateRequest.getMembers().stream() .map(BootcampCreateRequest.BootcampMemberRequest::getUserId) - .collect(Collectors.toList()); + .toList(); Map userMap = userService.getIdAndUserMap(userIds, userId -> userId); - Bootcamp bootcamp = bootcampMapper.toEntity(bootcampCreateRequest, imageUrl); + if (isInvalidUser(userMap)) { + throw new BootcampNotParticipantException(); + } + + Bootcamp bootcamp = BootcampMapper.toEntity(bootcampCreateRequest, imageUrl); List members = - bootcampMapper.toMemberEntities( + BootcampMapper.toMemberEntities( bootcamp, bootcampCreateRequest.getMembers(), userMap); members.forEach(bootcamp::addMember); Bootcamp savedBootcamp = bootcampRepository.save(bootcamp); - return bootcampMapper.toResponse(savedBootcamp); + return BootcampMapper.toResponse(savedBootcamp); + } + + private boolean isInvalidUser(Map userMap) { + return userMap.values().stream() + .anyMatch( + u -> + u.getBootcampYear() == null + || !u.getBootcampYear().equals(getCurrentBootcampYear())); } public Bootcamp findBootcampById(Long bootcampId) { @@ -68,13 +86,13 @@ public Bootcamp findBootcampById(Long bootcampId) { "해당 부트캠프를 찾을 수 없거나 이미 삭제되었습니다. ID: " + bootcampId)); } - public BootcampResponse getBootcamp(Long bootcampId) { - Bootcamp bootcamp = findBootcampById(bootcampId); - return bootcampMapper.toResponse(bootcamp); + public Integer getCurrentBootcampYear() { + return BootcampGeneration.calculateBootcampGeneration(LocalDate.now(clock)); } - public Integer getCurrentBootcampYear() { - return BootcampGeneration.calculateCurrentGeneration(); + public BootcampResponse getBootcamp(Long bootcampId) { + Bootcamp bootcamp = findBootcampById(bootcampId); + return BootcampMapper.toResponse(bootcamp); } // 비회원인 경우 부트캠프 조회 @@ -82,8 +100,7 @@ public BootcampListResponse getBootcampListForGuest( Boolean isAward, Integer year, Long cursorId, Integer rank, Integer limit) { PageSlice slice = fetchBootcamps(isAward, year, cursorId, rank, limit); - return bootcampMapper - .toListResponse( + return BootcampMapper.toListResponse( slice.getItems(), slice.getNextCursor(), slice.getNextCursorRank(), @@ -100,20 +117,18 @@ public BootcampListResponse getBootcampListWithUserGeneration( Integer year, Long cursorId, Integer rank, - Integer limit) { + Integer limit, + Integer currentYear) { PageSlice slice = fetchBootcamps(isAward, year, cursorId, rank, limit); - Integer userBootcampYear = userService.getUserBootcampYear(userId); - Boolean isParticipate = userService.isParticipate(userId); + Boolean isParticipate = userService.isParticipate(userId, currentYear); - return bootcampMapper - .toListResponseWithUserGeneration( + return BootcampMapper.toListResponseWithUserGeneration( slice.getItems(), slice.getNextCursor(), slice.getNextCursorRank(), - slice.isHasNext(), - userBootcampYear) + slice.isHasNext()) .toBuilder() .currentBootcampYear(getCurrentBootcampYear()) .isParticipate(isParticipate) @@ -129,7 +144,9 @@ public BootcampListResponse getBootcampList( Integer limit) { if (userId != null) { - return getBootcampListWithUserGeneration(userId, isAward, year, cursorId, rank, limit); + Integer currentYear = getCurrentBootcampYear(); + return getBootcampListWithUserGeneration( + userId, isAward, year, cursorId, rank, limit, currentYear); } else { return getBootcampListForGuest(isAward, year, cursorId, rank, limit); } @@ -145,10 +162,15 @@ public BootcampResponse updateBootcamp( List userIds = bootcampUpdateRequest.getMembers().stream() .map(BootcampCreateRequest.BootcampMemberRequest::getUserId) - .collect(Collectors.toList()); + .toList(); Map userMap = userService.getIdAndUserMap(userIds, userId -> userId); + + if (isInvalidUser(userMap)) { + throw new BootcampNotParticipantException(); + } + List newMembers = - bootcampMapper.toMemberEntities( + BootcampMapper.toMemberEntities( bootcamp, bootcampUpdateRequest.getMembers(), userMap); newMembers.forEach(bootcamp::addMember); @@ -165,7 +187,7 @@ public BootcampResponse updateBootcamp( bootcamp.getIsOpen() // 공개 여부는 이 API에서 수정하지 않음 ); - return bootcampMapper.toResponse(bootcamp); + return BootcampMapper.toResponse(bootcamp); } @Transactional @@ -207,7 +229,8 @@ public BootcampRankUpdateResponse updateBootcampRank( @Transactional public void toggleBootcampParticipation(Long userId) { - userService.toggleBootcampParticipation(userId); + Integer currentGeneration = getCurrentBootcampYear(); + userService.toggleBootcampParticipation(userId, currentGeneration); } // 공통 페이지네이션 로직 @@ -235,4 +258,13 @@ private int normalizeLimit(Integer limit) { if (limit == null || limit <= 0) return 20; return Math.min(limit, 100); } + + public boolean checkActiveMemberByTeamAndUser(Long bootcampId, Long userId) { + log.info("부트캠프 멤버 검증 시작: teamId={}, userId={}", bootcampId, userId); + final boolean exists = + bootcampMemberRepository.existsByBootcampIdAndUserIdAndIsDeletedFalse( + bootcampId, userId); + log.info("부트캠프 멤버 검증 시작: 존재 여부={}", exists); + return exists; + } } diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/controller/FeedbackController.java b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/controller/FeedbackController.java index 8d7a83cb..0c698a8f 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/controller/FeedbackController.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/controller/FeedbackController.java @@ -239,11 +239,22 @@ public ResponseEntity getReceivedFeedbackRequests( if (status != null && !status.trim().isEmpty()) { feedbacks = feedbackService.getReceivedFeedbacksByStatus( - currentUser.getUserId(), status, startDateTime, endDateTime, cursor, limit, currentUser); + currentUser.getUserId(), + status, + startDateTime, + endDateTime, + cursor, + limit, + currentUser); } else { feedbacks = feedbackService.getAllReceivedFeedbacks( - currentUser.getUserId(), startDateTime, endDateTime, cursor, limit, currentUser); + currentUser.getUserId(), + startDateTime, + endDateTime, + cursor, + limit, + currentUser); } FeedbackCursorResponse response = FeedbackCursorResponse.from(feedbacks, limit); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryCustom.java b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryCustom.java index 432dee19..90824d5e 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryCustom.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryCustom.java @@ -25,7 +25,12 @@ Optional findByRequesterIdAndRecipientIdAndIsDeletedFalse( Optional findByIdWithUsers(Long feedbackId); List findReceivedFeedbacks( - Long recipientId, String status, LocalDateTime startDateTime, LocalDateTime endDateTime, Long cursor, int limit); + Long recipientId, + String status, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Long cursor, + int limit); List findReceivedFeedbacksByStatus( Long recipientId, String status, Long cursor, int limit); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryImpl.java b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryImpl.java index 08718df9..4f79f433 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryImpl.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/repository/FeedbackRepositoryImpl.java @@ -170,7 +170,12 @@ public Optional findByIdWithUsers(Long feedbackId) { @Override public List findReceivedFeedbacks( - Long recipientId, String status, LocalDateTime startDateTime, LocalDateTime endDateTime, Long cursor, int limit) { + Long recipientId, + String status, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Long cursor, + int limit) { var query = queryFactory .selectFrom(feedback) @@ -179,7 +184,9 @@ public List findReceivedFeedbacks( .leftJoin(feedback.recipient) .fetchJoin() .where( - feedback.recipient.id.eq(recipientId) + feedback.recipient + .id + .eq(recipientId) .and(feedback.isDeleted.eq(false))); if (status != null) { @@ -200,4 +207,4 @@ public List findReceivedFeedbacks( return query.orderBy(feedback.createdAt.desc()).limit(limit + 1).fetch(); } -} \ No newline at end of file +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/service/FeedbackService.java b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/service/FeedbackService.java index 9bda9b25..23ee829e 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/feedback/service/FeedbackService.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/feedback/service/FeedbackService.java @@ -157,7 +157,13 @@ public List getReceivedFeedbackRequests(Long userId) { @Transactional(readOnly = true) public List getReceivedFeedbacksByStatus( - Long mentorId, String status, LocalDateTime startDateTime, LocalDateTime endDateTime, Long cursor, int limit, CustomUserPrincipal currentUser) { + Long mentorId, + String status, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Long cursor, + int limit, + CustomUserPrincipal currentUser) { logger.debug( "멘토 상태별 피드백 조회 시작 - mentorId: {}, status: {}, startDateTime: {}, endDateTime: {} | context: {}", mentorId, @@ -171,10 +177,18 @@ public List getReceivedFeedbacksByStatus( throw new FeedbackForbiddenException(); } - LocalDateTime effectiveStartDateTime = startDateTime != null ? startDateTime : LocalDateTime.now().minusMonths(1); + LocalDateTime effectiveStartDateTime = + startDateTime != null ? startDateTime : LocalDateTime.now().minusMonths(1); LocalDateTime effectiveEndDateTime = endDateTime; - List feedbacks = feedbackRepository.findReceivedFeedbacks(mentorId, status, effectiveStartDateTime, effectiveEndDateTime, cursor, limit); + List feedbacks = + feedbackRepository.findReceivedFeedbacks( + mentorId, + status, + effectiveStartDateTime, + effectiveEndDateTime, + cursor, + limit); logger.debug( "멘토 상태별 피드백 조회 완료 - mentorId: {}, status: {}, count: {} | context: {}", @@ -188,18 +202,36 @@ public List getReceivedFeedbacksByStatus( @Transactional(readOnly = true) public List getAllReceivedFeedbacks( - Long mentorId, LocalDateTime startDateTime, LocalDateTime endDateTime, Long cursor, int limit, CustomUserPrincipal currentUser) { - logger.debug("멘토 전체 피드백 조회 시작 - mentorId: {}, startDateTime: {}, endDateTime: {} | context: {}", mentorId, startDateTime, endDateTime, CONTEXT); + Long mentorId, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Long cursor, + int limit, + CustomUserPrincipal currentUser) { + logger.debug( + "멘토 전체 피드백 조회 시작 - mentorId: {}, startDateTime: {}, endDateTime: {} | context: {}", + mentorId, + startDateTime, + endDateTime, + CONTEXT); validateMentorRole(currentUser); if (!currentUser.getUserId().equals(mentorId) && !currentUser.isAdmin()) { throw new FeedbackForbiddenException(); } - LocalDateTime effectiveStartDateTime = startDateTime != null ? startDateTime : LocalDateTime.now().minusMonths(1); + LocalDateTime effectiveStartDateTime = + startDateTime != null ? startDateTime : LocalDateTime.now().minusMonths(1); LocalDateTime effectiveEndDateTime = endDateTime; - List feedbacks = feedbackRepository.findReceivedFeedbacks(mentorId, null, effectiveStartDateTime, effectiveEndDateTime, cursor, limit); + List feedbacks = + feedbackRepository.findReceivedFeedbacks( + mentorId, + null, + effectiveStartDateTime, + effectiveEndDateTime, + cursor, + limit); logger.debug( "멘토 전체 피드백 조회 완료 - mentorId: {}, count: {} | context: {}", diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/resume/alert/ResumeAlertNotifier.java b/techeerzip/src/main/java/backend/techeerzip/domain/resume/alert/ResumeAlertNotifier.java new file mode 100644 index 00000000..41876293 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/resume/alert/ResumeAlertNotifier.java @@ -0,0 +1,58 @@ +package backend.techeerzip.domain.resume.alert; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import backend.techeerzip.infra.slack.event.SlackEvent; +import backend.techeerzip.infra.slack.util.SlackChannelType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ResumeAlertNotifier { + private static final String ALERT_SENT_KEY_PREFIX = "resume_alert_sent:"; + + private final ApplicationEventPublisher eventPublisher; + private final RedisTemplate redisTemplate; + + public void notify( + String reason, String stage, String taskId, Long userId, Long resumeId, String detail) { + if (resumeId == null) { + log.warn("resumeId가 null이어서 알림 발송 불가 - taskId: {}", taskId); + return; + } + + final String alertKey = ALERT_SENT_KEY_PREFIX + resumeId; + + Boolean firstSent = redisTemplate.opsForValue().setIfAbsent(alertKey, "sent"); + if (Boolean.FALSE.equals(firstSent)) { + log.debug("이미 알림이 발송된 이력서 - resumeId: {}, taskId: {}", resumeId, taskId); + return; + } + + final String message = + String.format( + "❗️ 이력서 크롤링 실패\n- reason: %s\n- stage: %s\n- taskId: %s\n- userId: %s\n- resumeId: %s\n- detail: %s", + reason, + stage, + taskId, + String.valueOf(userId), + String.valueOf(resumeId), + detail != null ? detail : "-"); + + try { + eventPublisher.publishEvent( + SlackEvent.Channel.builder() + .channelType(SlackChannelType.RESUME) + .message(message) + .build()); + log.info("이력서 실패 알림 발송 완료 - resumeId: {}, taskId: {}", resumeId, taskId); + } catch (RuntimeException e) { + redisTemplate.delete(alertKey); + throw e; + } + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/resume/event/ResumeStackExtractionSaveListener.java b/techeerzip/src/main/java/backend/techeerzip/domain/resume/event/ResumeStackExtractionSaveListener.java index abe7a698..b1dd5e80 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/resume/event/ResumeStackExtractionSaveListener.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/resume/event/ResumeStackExtractionSaveListener.java @@ -7,6 +7,10 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import backend.techeerzip.domain.resume.alert.ResumeAlertNotifier; +import backend.techeerzip.domain.resume.monitoring.ResumeMetricsRecorder; +import backend.techeerzip.domain.resume.support.ResumeFailureClassifier; +import backend.techeerzip.domain.resume.support.ResumeTaskContextExtractor; import backend.techeerzip.domain.user.service.UserService; import backend.techeerzip.infra.redis.RedisTaskProcessedEvent; import lombok.RequiredArgsConstructor; @@ -20,6 +24,8 @@ public class ResumeStackExtractionSaveListener { private final UserService userService; private final ApplicationEventPublisher eventPublisher; + private final ResumeAlertNotifier alertNotifier; + private final ResumeMetricsRecorder metricsRecorder; @Async("defaultExecutor") @EventListener @@ -30,10 +36,41 @@ public void handleResumeStackSaveProcess(ResumeStackExtractionSaveEvent event) { event.getUserId(), CONTEXT); try { + // 총 시도 계측 + metricsRecorder.markTotal(); + + // 다운로드 실패, 파싱/모델 오류 분류 및 알림 + Optional classified = + ResumeFailureClassifier.classify(event.getResultData()); + if (classified.isPresent()) { + final String stage = classified.get().stage(); + final String reason = classified.get().reason(); + final Long resumeId = ResumeTaskContextExtractor.extractResumeId(event.getTaskId()); + metricsRecorder.markFail(reason, stage); + alertNotifier.notify( + reason, + stage, + event.getTaskId(), + event.getUserId(), + resumeId, + "classified-by-result"); + return; + } + final Optional response = ResumeStackMapper.toExtractionResponse( event.getUserId(), event.getResultData()); if (response.isEmpty()) { + final Long resumeId = ResumeTaskContextExtractor.extractResumeId(event.getTaskId()); + final String reason = "result_json_invalid"; + metricsRecorder.markFail(reason, "map_result"); + alertNotifier.notify( + reason, + "map_result", + event.getTaskId(), + event.getUserId(), + resumeId, + "json-parse-failed"); return; } userService.updateTechStack(response.get()); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/resume/monitoring/ResumeMetricsRecorder.java b/techeerzip/src/main/java/backend/techeerzip/domain/resume/monitoring/ResumeMetricsRecorder.java new file mode 100644 index 00000000..111a9cda --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/resume/monitoring/ResumeMetricsRecorder.java @@ -0,0 +1,25 @@ +package backend.techeerzip.domain.resume.monitoring; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ResumeMetricsRecorder { + private final MeterRegistry meterRegistry; + + public void markTotal() { + Counter.builder("resume_extraction_total").register(meterRegistry).increment(); + } + + public void markFail(String reason, String stage) { + Counter.builder("resume_extraction_fail_total") + .tag("reason", reason) + .tag("stage", stage) + .register(meterRegistry) + .increment(); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/resume/scheduler/ResumeTaskWatchdog.java b/techeerzip/src/main/java/backend/techeerzip/domain/resume/scheduler/ResumeTaskWatchdog.java new file mode 100644 index 00000000..a186b172 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/resume/scheduler/ResumeTaskWatchdog.java @@ -0,0 +1,137 @@ +package backend.techeerzip.domain.resume.scheduler; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import backend.techeerzip.domain.resume.alert.ResumeAlertNotifier; +import backend.techeerzip.domain.resume.monitoring.ResumeMetricsRecorder; +import backend.techeerzip.domain.resume.support.ResumeTaskContextExtractor; +import backend.techeerzip.infra.redis.RedisTaskReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ResumeTaskWatchdog { + private final RedisTemplate redisTemplate; + private final RedisTaskReader redisTaskReader; + private final ResumeAlertNotifier alertNotifier; + private final ResumeMetricsRecorder metrics; + + private static final Duration TIMEOUT = Duration.ofMinutes(20); + + @Scheduled(fixedRate = 60_000) + public void checkTimeouts() { + try { + // "resume_extraction--" 형식 + final Set taskIds = scanKeys("resume_extraction-*"); + if (taskIds == null || taskIds.isEmpty()) { + return; + } + final Instant now = Instant.now(); + + for (String taskId : taskIds) { + try { + final Map details = redisTaskReader.read(taskId); + if (details == null || details.isEmpty()) { + continue; + } + final Long userId = parseLong(details.get("userId")); + final Long resumeId = ResumeTaskContextExtractor.extractResumeId(taskId); + + final Instant createdAt = parseInstant(details.get("createdAt")); + final String status = String.valueOf(details.getOrDefault("status", "")); + final boolean processed = + "PROCESSED".equalsIgnoreCase(status) + || "COMPLETED".equalsIgnoreCase(status); + final boolean hasResult = details.containsKey("result"); + + if (createdAt != null && !processed && createdAt.isBefore(now.minus(TIMEOUT))) { + metrics.markFail("worker_timeout", "worker_execute"); + alertNotifier.notify( + "worker_timeout", + "worker_execute", + taskId, + userId, + resumeId, + "no-completion-within-20m"); + redisTemplate.delete(taskId); + log.info("타임아웃 태스크 정리됨 - taskId: {}", taskId); + continue; + } + + if (processed && !hasResult) { + metrics.markFail("result_missing", "worker_execute"); + alertNotifier.notify( + "result_missing", + "worker_execute", + taskId, + userId, + resumeId, + "processed-without-result"); + redisTemplate.delete(taskId); + log.info("결과 누락 태스크 정리됨 - taskId: {}", taskId); + } + } catch (Exception e) { + log.warn( + "Watchdog inspection failed - taskId: {}, err: {}", + taskId, + e.getMessage()); + } + } + } catch (Exception e) { + log.warn("Watchdog scan failed - err: {}", e.getMessage()); + } + } + + private static Long parseLong(Object v) { + try { + if (v == null) return null; + return Long.parseLong(String.valueOf(v)); + } catch (Exception e) { + return null; + } + } + + private static Instant parseInstant(Object v) { + try { + if (v == null) return null; + long epochMillis = Long.parseLong(String.valueOf(v)); + return Instant.ofEpochMilli(epochMillis); + } catch (Exception e) { + return null; + } + } + + /** + * SCAN 기반으로 패턴에 매칭되는 키 조회 + * KEYS 명령어의 블로킹 문제 회피 + */ + private Set scanKeys(String pattern) { + Set keys = new HashSet<>(); + redisTemplate.execute((RedisCallback) connection -> { + try (Cursor cursor = connection.scan( + ScanOptions.scanOptions() + .match(pattern) + .count(100) + .build())) { + cursor.forEachRemaining(keyBytes -> + keys.add(new String(keyBytes, StandardCharsets.UTF_8))); + } + return null; + } ); + return keys; + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/resume/support/ResumeFailureClassifier.java b/techeerzip/src/main/java/backend/techeerzip/domain/resume/support/ResumeFailureClassifier.java new file mode 100644 index 00000000..a65865ac --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/resume/support/ResumeFailureClassifier.java @@ -0,0 +1,46 @@ +package backend.techeerzip.domain.resume.support; + +import java.util.Optional; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class ResumeFailureClassifier { + private static final ObjectMapper mapper = new ObjectMapper(); + + public static Optional classify(String resultJson) { + try { + final JsonNode root = mapper.readTree(resultJson); + final String errorCode = + getFirstNonNullText(root, "errorCode", "error", "code", "status"); + if (errorCode == null) return Optional.empty(); + + if (match(errorCode, "download_failed", "unauthorized_url", "not_found")) { + return Optional.of(new FailureInfo("worker_download", errorCode)); + } + if (match(errorCode, "parsing_failed", "ocr_failed", "model_error")) { + return Optional.of(new FailureInfo("worker_parse", errorCode)); + } + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static boolean match(String code, String... candidates) { + for (String c : candidates) if (c.equalsIgnoreCase(code)) return true; + return false; + } + + private static String getFirstNonNullText(JsonNode node, String... keys) { + for (String k : keys) { + if (node.has(k) && !node.get(k).isNull()) { + final String v = node.get(k).asText(null); + if (v != null && !v.isBlank()) return v; + } + } + return null; + } + + public record FailureInfo(String stage, String reason) {} +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/resume/support/ResumeTaskContextExtractor.java b/techeerzip/src/main/java/backend/techeerzip/domain/resume/support/ResumeTaskContextExtractor.java new file mode 100644 index 00000000..d66b4205 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/resume/support/ResumeTaskContextExtractor.java @@ -0,0 +1,11 @@ +package backend.techeerzip.domain.resume.support; + +import backend.techeerzip.domain.task.dto.TaskIdInfo; +import backend.techeerzip.domain.task.util.TaskIdHandler; + +public class ResumeTaskContextExtractor { + public static Long extractResumeId(String taskId) { + final TaskIdInfo info = TaskIdHandler.extractTaskId(taskId); + return info.getDomainId(); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java b/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java new file mode 100644 index 00000000..df360e2c --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java @@ -0,0 +1,40 @@ +package backend.techeerzip.domain.todayCs.scheduler; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import backend.techeerzip.domain.todayCs.exception.CsProblemNotFoundException; +import backend.techeerzip.domain.todayCs.service.TodayCsService; +import backend.techeerzip.infra.slack.event.SlackEvent; +import backend.techeerzip.infra.slack.util.SlackChannelType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TodayCsScheduler { + private final TodayCsService todayCsService; + private final ApplicationEventPublisher eventPublisher; + + private static final String CONTEXT = "TodayCsScheduler"; + + @Scheduled(cron = "0 0 10 * * MON", zone = "Asia/Seoul") // 월요일 오전 10시에 실행될 작업 + public void todayCsPublishScheduler() { + try { + todayCsService.publishCsProblem(null); + } catch (CsProblemNotFoundException e) { + final String warningMessage = "[WARNING] 출제 할 문제가 없습니다!"; + eventPublisher.publishEvent( + new SlackEvent.Channel(warningMessage, SlackChannelType.EA)); + } catch (Exception e) { + log.error("[{}] CS 문제 발행 중 예상치 못한 오류 발생: {}", CONTEXT, e.getMessage(), e); + final String errorMessage = + String.format( + "[ERROR] CS 문제 발행 중 오류 발생!\n> 원인: %s\n> 시간: %s", + e.getMessage(), java.time.LocalDateTime.now()); + eventPublisher.publishEvent(new SlackEvent.Channel(errorMessage, SlackChannelType.EA)); + } + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/user/controller/UserController.java b/techeerzip/src/main/java/backend/techeerzip/domain/user/controller/UserController.java index a680b0bd..f3624a3d 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/user/controller/UserController.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/user/controller/UserController.java @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import backend.techeerzip.domain.bootcamp.entity.BootcampGeneration; +import backend.techeerzip.domain.bootcamp.service.BootcampService; import backend.techeerzip.domain.user.dto.request.CreateExternalUserRequest; import backend.techeerzip.domain.user.dto.request.CreateUserPermissionRequest; import backend.techeerzip.domain.user.dto.request.CreateUserWithResumeRequest; @@ -43,12 +43,15 @@ import backend.techeerzip.global.resolver.CurrentUser; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestController @RequestMapping("/api/v3/users") @RequiredArgsConstructor public class UserController implements UserSwagger { private final UserService userService; + private final BootcampService bootcampService; private final CustomLogger logger; private static final String CONTEXT = "UserController"; @@ -241,10 +244,8 @@ public ResponseEntity getAllProfiles( public ResponseEntity getBootcampMemberProfiles( @ModelAttribute GetBootcampMemberRequest bootcampMemberRequest) { logger.info("현재 부트캠프 기수 멤버 프로필 조회 요청 처리 중", CONTEXT); - // 현재 부트캠프 기수 자동 계산 - Integer currentBootcampYear = BootcampGeneration.calculateCurrentGeneration(); - + Integer currentBootcampYear = bootcampService.getCurrentBootcampYear(); BootcampMemberListResponse profiles = userService.getBootcampMemberProfiles( bootcampMemberRequest.getCursorId(), @@ -252,9 +253,8 @@ public ResponseEntity getBootcampMemberProfiles( bootcampMemberRequest.getSortBy(), currentBootcampYear); - logger.info( - "현재 부트캠프 기수 멤버 프로필 조회 요청 처리 완료 - bootcampYear: {}, count: {}", - currentBootcampYear, + log.info( + "현재 부트캠프 기수 멤버 프로필 조회 요청 처리 완료 - count: {}, context: {}", profiles.getProfiles().size(), CONTEXT); return ResponseEntity.ok(profiles); diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/user/service/UserService.java b/techeerzip/src/main/java/backend/techeerzip/domain/user/service/UserService.java index 91223092..f0dfa84d 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/user/service/UserService.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/user/service/UserService.java @@ -13,21 +13,17 @@ import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.ResponseCookie; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import backend.techeerzip.domain.auth.exception.AuthNotVerifiedEmailException; import backend.techeerzip.domain.auth.service.AuthService; import backend.techeerzip.domain.blog.repository.BlogRepository; import backend.techeerzip.domain.bookmark.repository.BookmarkRepository; -import backend.techeerzip.domain.bootcamp.entity.BootcampGeneration; import backend.techeerzip.domain.event.repository.EventRepository; import backend.techeerzip.domain.like.repository.LikeRepository; import backend.techeerzip.domain.projectMember.repository.ProjectMemberRepository; @@ -101,17 +97,6 @@ public class UserService { private final UserExperienceRepository userExperienceRepository; private final TechStackRepository techStackRepository; private final StackService stackService; - - @Value("${PROFILE_IMG_URL}") - private String profileImgUrl; - - @Value("${SLACK}") - private String slackSecretKey; - - @Value("${X_API_KEY}") - private String xApiKey; - - private final RestTemplate restTemplate; private final AuthService authService; private final ResumeService resumeService; private final PasswordEncoder passwordEncoder; @@ -137,7 +122,6 @@ public class UserService { private final ApplicationEventPublisher eventPublisher; private final StatisticDataCollectionService statisticDataCollectionService; private final GitHubApiService gitHubApiService; - private final PlatformTransactionManager transactionManager; @Transactional public void signUp( @@ -508,8 +492,7 @@ public void deleteUser(Long userId, HttpServletResponse response) { public void resetPassword(String email, String code, String newPassword) { authService.verifyCode(email, code); - User user = - userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException()); + User user = userRepository.findByEmail(email).orElseThrow(UserNotFoundException::new); user.setPassword(passwordEncoder.encode(newPassword)); } @@ -735,9 +718,9 @@ public GetUserProfileListResponse getAllProfiles( .toList(); boolean hasNext = filteredUsers.size() > limit; - List users = hasNext ? filteredUsers.subList(0, limit) : filteredUsers; + List users = getPagedUsers(hasNext, filteredUsers, limit); - Long nextCursor = hasNext && !users.isEmpty() ? users.get(users.size() - 1).getId() : null; + Long nextCursor = getNextCursorId(hasNext, users); List responses = users.stream().map(UserMapper::toGetUserResponse).toList(); @@ -747,7 +730,7 @@ public GetUserProfileListResponse getAllProfiles( @Transactional(readOnly = true) public BootcampMemberListResponse getBootcampMemberProfiles( - Long cursorId, Integer limit, String sortBy, Integer bootcampYear) { + Long cursorId, Integer limit, String sortBy, Integer currentBootcampYear) { int actualLimit = limit != null ? limit : 20; String actualSortBy = sortBy != null ? sortBy : "name"; @@ -755,13 +738,12 @@ public BootcampMemberListResponse getBootcampMemberProfiles( // hasNext 판별을 위해 limit + 1개를 조회 List fetchedUsers = userRepository.findBootcampMembersWithCursor( - cursorId, bootcampYear, actualLimit + 1, actualSortBy); + cursorId, currentBootcampYear, actualLimit + 1, actualSortBy); // 다음 페이지 존재 여부 판별 boolean hasNext = fetchedUsers.size() > actualLimit; - List users = hasNext ? fetchedUsers.subList(0, actualLimit) : fetchedUsers; - - Long nextCursor = hasNext && !users.isEmpty() ? users.get(users.size() - 1).getId() : null; + List users = getPagedUsers(hasNext, fetchedUsers, actualLimit); + Long nextCursor = getNextCursorId(hasNext, users); List responses = users.stream().map(UserMapper::toBootcampMemberResponse).toList(); @@ -773,6 +755,15 @@ public BootcampMemberListResponse getBootcampMemberProfiles( .build(); } + private static Long getNextCursorId(boolean hasNext, List users) { + return hasNext && !users.isEmpty() ? users.getLast().getId() : null; + } + + private static List getPagedUsers( + boolean hasNext, List fetchedUsers, int actualLimit) { + return hasNext ? fetchedUsers.subList(0, actualLimit) : fetchedUsers; + } + public Map getIdAndUserMap(List usersInfo, Function idExtractor) { final List usersId = usersInfo.stream().map(idExtractor).toList(); final List users = userRepository.findAllById(usersId); @@ -826,7 +817,7 @@ public Integer getUserBootcampYear(Long userId) { return user.getBootcampYear(); } - public boolean isParticipate(Long userId) { + public boolean isParticipate(Long userId, Integer currentGeneration) { User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); Integer userBootcampYear = user.getBootcampYear(); @@ -834,17 +825,15 @@ public boolean isParticipate(Long userId) { return false; } - Integer currentGeneration = BootcampGeneration.calculateCurrentGeneration(); return userBootcampYear.equals(currentGeneration); } @Transactional - public void toggleBootcampParticipation(Long userId) { + public void toggleBootcampParticipation(Long userId, Integer currentGeneration) { User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); - Integer currentGeneration = BootcampGeneration.calculateCurrentGeneration(); // 현재 참여 중인지 확인 - boolean isCurrentlyParticipating = isParticipate(userId); + boolean isCurrentlyParticipating = isParticipate(userId, currentGeneration); if (isCurrentlyParticipating) { // 참여 취소 diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java index 19015da2..a55f6cde 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/repository/ZoomAttendanceRepository.java @@ -37,6 +37,12 @@ Optional findActiveSessionByParticipantUuidAndMeetingDate( @Param("participantUuid") String participantUuid, @Param("meetingDate") LocalDate meetingDate); + /** participantUuid로 활성 세션 조회 (날짜 경계 문제 해결을 위해 날짜 조건 제외) */ + @Query( + "SELECT z FROM ZoomAttendance z WHERE z.participantUuid = :participantUuid AND z.leaveTime IS NULL") + Optional findActiveSessionByParticipantUuid( + @Param("participantUuid") String participantUuid); + /** 특정 사용자의 월별 통계 조회 */ @Query( "SELECT new backend.techeerzip.domain.zoom.dto.response.ZoomMonthlyStatsDto(" diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java index 78d0520b..11b83798 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceService.java @@ -10,8 +10,8 @@ import backend.techeerzip.domain.zoom.entity.ZoomAttendance; import backend.techeerzip.domain.zoom.repository.ZoomAttendanceRepository; -import backend.techeerzip.infra.zoom.client.ZoomApiClient; -import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent.WebhookParticipant; +import backend.techeerzip.infra.zoom.type.ZoomLeaveReason; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,49 +21,17 @@ @Transactional public class ZoomAttendanceService { - private final ZoomApiClient zoomApiClient; private final ZoomAttendanceRepository zoomAttendanceRepository; - /** API 연결 테스트 */ - public boolean testConnection() { - return zoomApiClient.testConnection(); - } - - /** 웹훅 이벤트 처리 */ - public void handleWebhookEvent(ZoomWebhookEvent event) { - try { - String eventType = event.getEvent(); - - if ("meeting.participant_joined".equals(eventType)) { - handleParticipantJoined(event); - } else if ("meeting.participant_left".equals(eventType)) { - handleParticipantLeft(event); - } - - log.debug("ZoomService 처리 완료: {}", eventType); - - } catch (Exception e) { - log.error("ZoomService 처리 오류: {}", e.getMessage(), e); - } - } - /** 참가자 입장 이벤트 처리 */ - private void handleParticipantJoined(ZoomWebhookEvent event) { + public void handleParticipantJoined(WebhookParticipant participant) { try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant == null) { - log.warn("참가자 정보가 없습니다."); - return; - } - String participantUuid = participant.getParticipantUuid(); String userName = participant.getUserName(); String joinTimeStr = participant.getJoinTime(); - if (participantUuid == null || joinTimeStr == null) { - log.warn("필수 정보가 누락되었습니다. uuid: {}, joinTime: {}", participantUuid, joinTimeStr); + if (joinTimeStr == null) { + log.warn("joinTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid); return; } @@ -71,19 +39,24 @@ private void handleParticipantJoined(ZoomWebhookEvent event) { LocalDateTime joinTime = parseZoomDateTime(joinTimeStr); LocalDate meetingDate = joinTime.toLocalDate(); - // 기존 출석 기록 조회 - Optional existingRecord = - zoomAttendanceRepository.findByParticipantUuidAndMeetingDate( - participantUuid, meetingDate); + // 활성 세션 조회 (leaveTime이 null인 기록) + // participant_uuid는 링크 접속 시 생성되고, 완전히 나갔다 다시 들어오면 새로 할당됨 + // 따라서 같은 participant_uuid로 활성 세션이 있으면 = 소회의실에서 메인으로 복귀한 경우 + Optional activeSession = + zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid); - if (existingRecord.isPresent()) { - log.info( - "기존 출석 기록이 존재합니다. participantUuid: {}, date: {}", + if (activeSession.isPresent()) { + log.debug( + "활성 세션이 존재합니다. participantUuid: {}, date: {} (소회의실에서 메인으로 복귀 무시)", participantUuid, meetingDate); - return; // 이미 있는 경우 무시 (소회의실에서 복귀한 경우) + return; // 활성 세션이 있으면 무시 (소회의실에서 메인으로 복귀한 경우) } + // 완전히 나갔다 다시 들어온 경우 (새로운 participant_uuid) 또는 첫 입장 + // 기존에 완료된 세션이 있는지 확인 (같은 날짜에 다른 participant_uuid로 기록이 있을 수 있음) + // 하지만 participant_uuid가 다르면 새로운 세션이므로 별도 엔티티로 저장 + // 새로운 출석 기록 생성 (입장 시에는 leaveTime과 durationMinutes는 null) ZoomAttendance attendance = ZoomAttendance.builder() @@ -109,53 +82,43 @@ private void handleParticipantJoined(ZoomWebhookEvent event) { } /** 참가자 퇴장 이벤트 처리 */ - private void handleParticipantLeft(ZoomWebhookEvent event) { + public void handleParticipantLeft(WebhookParticipant participant) { try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant == null) { - log.warn("참가자 정보가 없습니다."); - return; - } - String participantUuid = participant.getParticipantUuid(); String leaveReason = participant.getLeaveReason(); String leaveTimeStr = participant.getLeaveTime(); - if (participantUuid == null || leaveTimeStr == null) { - log.warn("필수 정보가 누락되었습니다. uuid: {}, leaveTime: {}", participantUuid, leaveTimeStr); + if (leaveTimeStr == null) { + log.warn("leaveTimeStr 정보가 누락되었습니다. uuid: {}", participantUuid); return; } - // 소회의실 관련 이벤트 무시 - if (leaveReason != null) { - if (leaveReason.contains("join breakout room") - || leaveReason.contains("leave breakout room to join main meeting")) { - log.debug("소회의실 관련 이벤트 무시: {} - {}", participantUuid, leaveReason); - return; - } + // 완전히 나간 케이스만 처리 (소회의실 이동, 대기실 관련 등은 제외) + if (!ZoomLeaveReason.isCompleteExit(leaveReason)) { + log.debug( + "완전히 나간 케이스가 아닙니다. participantUuid: {}, leaveReason: {} (무시)", + participantUuid, + leaveReason); + return; } // 시간 파싱 LocalDateTime leaveTime = parseZoomDateTime(leaveTimeStr); - LocalDate meetingDate = leaveTime.toLocalDate(); - // 기존 출석 기록 조회 - Optional existingRecord = - zoomAttendanceRepository.findByParticipantUuidAndMeetingDate( - participantUuid, meetingDate); + // 활성 세션 조회 (leaveTime이 null인 기록만) + // participantUuid만으로 조회하여 날짜 경계 문제 해결 (밤 11시 입장 → 다음 날 새벽 퇴장 케이스) + Optional activeSession = + zoomAttendanceRepository.findActiveSessionByParticipantUuid(participantUuid); - if (existingRecord.isEmpty()) { - log.warn( - "출석 기록을 찾을 수 없습니다. participantUuid: {}, date: {}", - participantUuid, - meetingDate); - return; + if (activeSession.isEmpty()) { + log.debug( + "활성 세션을 찾을 수 없습니다. participantUuid: {} (이미 완료된 세션이거나 소회의실 퇴장)", + participantUuid); + return; // 활성 세션이 없으면 무시 (이미 완료된 세션이거나 소회의실 퇴장) } - // 퇴장 시간 업데이트 - ZoomAttendance attendance = existingRecord.get(); + // 활성 세션의 퇴장 시간 업데이트 (완전히 나간 경우) + ZoomAttendance attendance = activeSession.get(); attendance.updateLeaveTime(leaveTime); zoomAttendanceRepository.save(attendance); diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/PyroscopeConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/PyroscopeConfig.java index c5cbb641..5a21200a 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/config/PyroscopeConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/PyroscopeConfig.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import io.pyroscope.http.Format; import io.pyroscope.javaagent.EventType; @@ -13,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; @Configuration +@Profile("!test") @Slf4j public class PyroscopeConfig { diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/RedisConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/RedisConfig.java index 755f098b..30ca8869 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/config/RedisConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/RedisConfig.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; @@ -24,6 +25,7 @@ @Slf4j @Configuration +@Profile("!test") public class RedisConfig { public static final String HANDLE_MESSAGE = "handleMessage"; diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java index 972fe0d7..871be1a5 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/SecurityConfig.java @@ -7,6 +7,7 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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; @@ -36,10 +37,9 @@ public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws http.securityMatcher("/api/v3/docs/**", "/api/v3/swagger-ui/**", "/api/v3/api-docs/**") .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.realmName("Swagger API Documentation")) - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) .sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - return http.build(); } @@ -111,7 +111,7 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce .permitAll() .anyRequest() .authenticated()) - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) .exceptionHandling( ex -> ex.authenticationEntryPoint(customAuthenticationEntryPoint) diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/TimeConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/TimeConfig.java new file mode 100644 index 00000000..f6f54246 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/TimeConfig.java @@ -0,0 +1,15 @@ +package backend.techeerzip.global.config; + +import java.time.Clock; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java b/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java index 70f73415..afa8da1f 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/config/WebConfig.java @@ -15,7 +15,7 @@ @Configuration public class WebConfig implements WebMvcConfigurer { - private OctetStreamReadMsgConverter octetStreamReadMsgConverter; + private final OctetStreamReadMsgConverter octetStreamReadMsgConverter; @Override public void addCorsMappings(CorsRegistry registry) { diff --git a/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java b/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java index e3df96a0..2357b235 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java @@ -59,6 +59,7 @@ public enum ErrorCode { BOOTCAMP_NOT_FOUND(HttpStatus.NOT_FOUND, "BC001", "부트캠프를 찾을 수 없습니다."), BOOTCAMP_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "BC002", "부트캠프 멤버를 찾을 수 없습니다."), BOOTCAMP_NOT_IN_PERIOD(HttpStatus.BAD_REQUEST, "BC003", "부트캠프 생성 기간이 아닙니다."), + BOOTCAMP_NOT_PARTICIPANT(HttpStatus.NOT_FOUND, "BC004", "부트캠프 참여자가 아닙니다."), // Team TEAM_INVALID_RECRUIT_NUM(HttpStatus.BAD_REQUEST, "T001", "모집 인원이 음수 입니다."), @@ -224,7 +225,21 @@ public enum ErrorCode { GITHUB_INVALID_URL(HttpStatus.BAD_REQUEST, "GH001", "유효하지 않은 GitHub URL입니다."), GITHUB_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "GH002", "GitHub 사용자를 찾을 수 없습니다. 사용자명을 확인해주세요."), GITHUB_API_ERROR(HttpStatus.BAD_GATEWAY, "GH003", "GitHub API 호출 중 오류가 발생했습니다."), - GITHUB_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "GH004", "GitHub 서버와 통신할 수 없습니다."); + GITHUB_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "GH004", "GitHub 서버와 통신할 수 없습니다."), + + // Zoom + ZOOM_WEBHOOK_SECRET_TOKEN_NOT_CONFIGURED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM001", "웹훅 secret token이 설정되지 않았습니다."), + ZOOM_TOKEN_ENCRYPTION_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM002", "토큰 암호화 중 오류가 발생했습니다."), + ZOOM_OAUTH_TOKEN_GENERATION_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM003", "Zoom OAuth 토큰 생성에 실패했습니다."), + ZOOM_WEBHOOK_AUTHENTICATION_FAILED( + HttpStatus.UNAUTHORIZED, "ZOOM004", "Zoom Webhook 인증에 실패했습니다."), + ZOOM_WEBHOOK_PROCESSING_FAILED( + HttpStatus.INTERNAL_SERVER_ERROR, "ZOOM005", "Zoom Webhook 처리 중 오류가 발생했습니다."), + ZOOM_WEBHOOK_PLAIN_TOKEN_MISSING( + HttpStatus.BAD_REQUEST, "ZOOM006", "URL 검증 요청에 plainToken이 없습니다."); private final HttpStatus status; private final String code; diff --git a/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java b/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java index a094395f..18b6c1c9 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/exception/GlobalExceptionHandler.java @@ -27,7 +27,7 @@ public ResponseEntity handleValidationExceptions( e.getBindingResult() .getAllErrors() .forEach( - (error) -> { + error -> { if (error instanceof FieldError fieldError) { String fieldName = fieldError.getField(); String errorMessage = error.getDefaultMessage(); diff --git a/techeerzip/src/main/java/backend/techeerzip/global/permission/BootcampPermissionEvaluator.java b/techeerzip/src/main/java/backend/techeerzip/global/permission/BootcampPermissionEvaluator.java new file mode 100644 index 00000000..bac45a98 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/global/permission/BootcampPermissionEvaluator.java @@ -0,0 +1,68 @@ +package backend.techeerzip.global.permission; + +import java.io.Serializable; +import java.util.Objects; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import backend.techeerzip.domain.auth.jwt.CustomUserPrincipal; +import backend.techeerzip.domain.bootcamp.service.BootcampService; +import backend.techeerzip.global.exception.PermissionDeniedException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BootcampPermissionEvaluator implements DomainPermissionEvaluator { + private final BootcampService bootcampService; + + @Override + public boolean supports(String targetType) { + return "BOOTCAMP".equals(targetType); + } + + @Override + @Transactional(readOnly = true) + public boolean hasPermission( + Authentication auth, Serializable targetId, String targetType, String permission) { + if (!supports(targetType)) { + return false; + } + + CustomUserPrincipal user = (CustomUserPrincipal) auth.getPrincipal(); + Long userId = user.getUserId(); + Long bootcampId = (Long) targetId; + Integer currentYear = bootcampService.getCurrentBootcampYear(); + boolean isPeriod = Objects.equals(user.getBootcampYear(), currentYear); + if (bootcampId == null && isCreatePermission(permission)) { + log.info("Bootcamp CREATE 권한 확인 완료 - userId: {}, permission: {}", userId, permission); + return isPeriod; + + } else if (targetId != null && isMemberPermission(permission)) { + boolean isMember = bootcampService.checkActiveMemberByTeamAndUser(bootcampId, userId); + + if (isPeriod && isMember) { + log.info( + "Bootcamp 권한 확인 완료 - userId: {}, bootcampId: {}, permission: {}", + userId, + bootcampId, + permission); + return true; + } + } + + log.warn("지원하지 않는 권한 요청 - userId: {}, permission: {}", userId, permission); + throw new PermissionDeniedException(); + } + + private boolean isMemberPermission(String permission) { + return permission.equals("UPDATE") || permission.equals("DELETE"); + } + + private static boolean isCreatePermission(String permission) { + return permission.equals("CREATE"); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/global/permission/DelegatingPermissionEvaluator.java b/techeerzip/src/main/java/backend/techeerzip/global/permission/DelegatingPermissionEvaluator.java index fd60d43f..37453b98 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/permission/DelegatingPermissionEvaluator.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/permission/DelegatingPermissionEvaluator.java @@ -65,7 +65,13 @@ public boolean hasPermission(Authentication auth, Serializable id, String type, boolean hasPermission = evaluators.stream() .filter(e -> e.supports(type)) - .anyMatch(e -> e.hasPermission(auth, (Long) id, type, permission)); + .anyMatch( + e -> { + if (id instanceof Long longId) { + return e.hasPermission(auth, longId, type, permission); + } + return e.hasPermission(auth, id, type, permission); + }); if (!hasPermission) { logger.warn("도메인 권한이 없습니다. - 사용자: {}, 권한: {}", auth.getName(), permission, CONTEXT); diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/slack/config/SlackProperties.java b/techeerzip/src/main/java/backend/techeerzip/infra/slack/config/SlackProperties.java index c029b8cc..d70edc74 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/slack/config/SlackProperties.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/slack/config/SlackProperties.java @@ -21,6 +21,7 @@ public class SlackProperties { @NotBlank private final String todayCsId; @NotBlank private final String blogChallengeId; @NotBlank private final String emergencyAlertId; + @NotBlank private final String resumeId; @ConstructorBinding public SlackProperties( @@ -31,7 +32,8 @@ public SlackProperties( String messageUrl, String todayCsId, String blogChallengeId, - String emergencyAlertId) { + String emergencyAlertId, + String resumeId) { this.environment = environment; this.channelUrl = channelUrl; this.dmUrl = dmUrl; @@ -40,5 +42,6 @@ public SlackProperties( this.todayCsId = todayCsId; this.blogChallengeId = blogChallengeId; this.emergencyAlertId = emergencyAlertId; + this.resumeId = resumeId; } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/slack/util/SlackChannelType.java b/techeerzip/src/main/java/backend/techeerzip/infra/slack/util/SlackChannelType.java index 3269874a..fffd9091 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/slack/util/SlackChannelType.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/slack/util/SlackChannelType.java @@ -7,7 +7,8 @@ public enum SlackChannelType { TBC(SlackProperties::getBlogChallengeId), TC(SlackProperties::getTodayCsId), - EA(SlackProperties::getEmergencyAlertId); + EA(SlackProperties::getEmergencyAlertId), + RESUME(SlackProperties::getResumeId); private final Function channelIdExtractor; diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/client/ZoomApiClient.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/client/ZoomApiClient.java deleted file mode 100644 index 9d9c7ac8..00000000 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/client/ZoomApiClient.java +++ /dev/null @@ -1,133 +0,0 @@ -package backend.techeerzip.infra.zoom.client; - -import java.time.Instant; -import java.util.Base64; - -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import backend.techeerzip.domain.zoom.exception.ZoomApiException; -import backend.techeerzip.infra.zoom.config.ZoomApiConfig; -import backend.techeerzip.infra.zoom.dto.ZoomOAuthTokenResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ZoomApiClient { - - private final ZoomApiConfig zoomApiConfig; - private final RestTemplate zoomRestTemplate; - - private String cachedAccessToken; - private Instant tokenExpiryTime; - - /** OAuth 액세스 토큰 생성 및 캐싱 */ - public String getAccessToken() { - // 토큰이 만료되지 않았다면 캐시된 토큰 반환 (5분 여유 두고 갱신) - if (cachedAccessToken != null - && tokenExpiryTime != null - && Instant.now().plusSeconds(300).isBefore(tokenExpiryTime)) { - log.debug("Using cached access token"); - return cachedAccessToken; - } - - // 새 토큰 생성 - log.info("Generating new Zoom OAuth access token"); - ZoomOAuthTokenResponse tokenResponse = generateOAuthToken(); - - if (tokenResponse != null && tokenResponse.getAccessToken() != null) { - cachedAccessToken = tokenResponse.getAccessToken(); - // 토큰 만료 시간 설정 (현재 시간 + expires_in 초) - tokenExpiryTime = Instant.now().plusSeconds(tokenResponse.getExpiresIn()); - log.info( - "Successfully generated OAuth token, expires in {} seconds", - tokenResponse.getExpiresIn()); - } else { - log.error("Failed to generate OAuth token"); - throw new ZoomApiException("Failed to generate Zoom OAuth token"); - } - - return cachedAccessToken; - } - - /** OAuth 토큰 생성 (Server-to-Server OAuth) */ - private ZoomOAuthTokenResponse generateOAuthToken() { - try { - String tokenUrl = zoomApiConfig.getTokenUrl(); - - // Basic Auth 헤더 생성 - String credentials = - zoomApiConfig.getClientId() + ":" + zoomApiConfig.getClientSecret(); - String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes()); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - headers.set("Authorization", "Basic " + encodedCredentials); - - // 요청 바디 생성 - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", "account_credentials"); - body.add("account_id", zoomApiConfig.getAccountId()); - - HttpEntity> entity = new HttpEntity<>(body, headers); - - ResponseEntity response = - zoomRestTemplate.exchange( - tokenUrl, HttpMethod.POST, entity, ZoomOAuthTokenResponse.class); - - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else { - log.error("Failed to get OAuth token, status: {}", response.getStatusCode()); - return null; - } - - } catch (Exception e) { - log.error("Error generating OAuth token: {}", e.getMessage(), e); - throw new ZoomApiException("Failed to generate Zoom OAuth token", e); - } - } - - /** 인증 헤더 생성 */ - private HttpHeaders createAuthHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + getAccessToken()); - headers.setContentType(MediaType.APPLICATION_JSON); - return headers; - } - - /** API 연결 테스트 - 사용자 정보 조회로 연결 확인 */ - public boolean testConnection() { - try { - String url = zoomApiConfig.getBaseUrl() + "/users/me"; - - HttpHeaders headers = createAuthHeaders(); - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = - zoomRestTemplate.exchange(url, HttpMethod.GET, entity, String.class); - - if (response.getStatusCode() == HttpStatus.OK) { - log.info("✅ Zoom API 연결 테스트 성공"); - return true; - } else { - log.warn("❌ Zoom API 연결 테스트 실패: {}", response.getStatusCode()); - return false; - } - - } catch (Exception e) { - log.error("❌ Zoom API 연결 테스트 중 오류: {}", e.getMessage(), e); - return false; - } - } -} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java index 792286dd..4cf30560 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomApiConfig.java @@ -1,9 +1,7 @@ package backend.techeerzip.infra.zoom.config; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; import lombok.Getter; @@ -11,21 +9,6 @@ @Getter public class ZoomApiConfig { - @Value("${zoom.api.base-url:https://api.zoom.us/v2}") - private String baseUrl; - - @Value("${zoom.api.token-url:https://zoom.us/oauth/token}") - private String tokenUrl; - - @Value("${zoom.api.account-id}") - private String accountId; - - @Value("${zoom.api.client-id}") - private String clientId; - - @Value("${zoom.api.client-secret}") - private String clientSecret; - @Value("${zoom.webhook.secret-token:}") private String webhookSecretToken; @@ -34,9 +17,4 @@ public class ZoomApiConfig { @Value("${zoom.meetings.default}") private String defaultMeetingId; - - @Bean - public RestTemplate zoomRestTemplate() { - return new RestTemplate(); - } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomAuth.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomAuth.java new file mode 100644 index 00000000..9f193372 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomAuth.java @@ -0,0 +1,10 @@ +package backend.techeerzip.infra.zoom.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ZoomAuth {} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomTokenProvider.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomTokenProvider.java new file mode 100644 index 00000000..39126031 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomTokenProvider.java @@ -0,0 +1,56 @@ +package backend.techeerzip.infra.zoom.config; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.infra.zoom.exception.ZoomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ZoomTokenProvider { + + private final ZoomApiConfig zoomApiConfig; + + /** Zoom 웹훅 URL 검증을 위한 암호화된 토큰 생성 plainToken과 webhookSecretToken을 사용하여 HMAC-SHA256으로 암호화 */ + public String generateEncryptedToken(String plainToken) { + String secretToken = zoomApiConfig.getWebhookSecretToken(); + + if (secretToken == null || secretToken.isEmpty()) { + log.error("Webhook secret token이 설정되지 않았습니다"); + throw new ZoomException(ErrorCode.ZOOM_WEBHOOK_SECRET_TOKEN_NOT_CONFIGURED); + } + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = + new SecretKeySpec(secretToken.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + + byte[] encryptedBytes = mac.doFinal(plainToken.getBytes(StandardCharsets.UTF_8)); + + // 16진수 문자열로 변환 + StringBuilder hexString = new StringBuilder(); + for (byte b : encryptedBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + + } catch (Exception e) { + log.error("토큰 암호화 중 오류: {}", e.getMessage(), e); + throw new ZoomException(ErrorCode.ZOOM_TOKEN_ENCRYPTION_FAILED, e); + } + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomWebhookAspect.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomWebhookAspect.java new file mode 100644 index 00000000..937ddb6a --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/config/ZoomWebhookAspect.java @@ -0,0 +1,82 @@ +package backend.techeerzip.infra.zoom.config; + +import java.util.Arrays; + +import jakarta.servlet.http.HttpServletRequest; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.exception.ZoomWebhookInvalidAuthenticationException; +import backend.techeerzip.infra.zoom.type.ZoomWebhookEventType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class ZoomWebhookAspect { + + private final ZoomApiConfig zoomApiConfig; + + @Before("@annotation(backend.techeerzip.infra.zoom.config.ZoomAuth)") + public void validationZoomToken(JoinPoint joinPoint) { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); + HttpServletRequest request = attributes.getRequest(); + + ZoomWebhookEvent event = + Arrays.stream(joinPoint.getArgs()) + .filter(ZoomWebhookEvent.class::isInstance) + .map(ZoomWebhookEvent.class::cast) + .findFirst() + .orElseThrow(ZoomWebhookInvalidAuthenticationException::new); + + if (ZoomWebhookEventType.isUrlValidation(event.getEventName())) { + log.debug("URL 검증 요청 - 인증 검증 생략"); + return; + } + + // 일반 웹훅 이벤트는 인증 검증 + String authHeader = + request.getHeader("authorization") != null + ? request.getHeader("authorization") + : request.getHeader("Authorization"); + + if (!isValidWebhook(authHeader)) { + log.warn("Invalid webhook authorization header"); + throw new ZoomWebhookInvalidAuthenticationException(); + } + } + + /** Webhook 인증 검증 */ + private boolean isValidWebhook(String authHeader) { + // Webhook verification token이 설정되지 않은 경우 + if (zoomApiConfig.getWebhookVerificationToken() == null + || zoomApiConfig.getWebhookVerificationToken().isEmpty()) { + log.warn("Webhook verification token not configured"); + return false; + } + + if (authHeader == null || authHeader.trim().isEmpty()) { + log.warn("Missing authorization header"); + return false; + } + + // Zoom은 Bearer 접두사 없이 토큰만 보냄 + String token = authHeader.trim(); + boolean isValid = zoomApiConfig.getWebhookVerificationToken().equals(token); + + if (!isValid) { + log.warn("Invalid webhook token"); + } + + return isValid; + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomOAuthTokenResponse.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomOAuthTokenResponse.java deleted file mode 100644 index 8da1a1d8..00000000 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomOAuthTokenResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package backend.techeerzip.infra.zoom.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -@Getter -@NoArgsConstructor -@ToString -public class ZoomOAuthTokenResponse { - - @JsonProperty("access_token") - private String accessToken; - - @JsonProperty("token_type") - private String tokenType; - - @JsonProperty("expires_in") - private Long expiresIn; - - @JsonProperty("scope") - private String scope; -} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java index 95a89b15..55cd6bba 100644 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookEvent.java @@ -1,27 +1,33 @@ package backend.techeerzip.infra.zoom.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @Getter @NoArgsConstructor @Slf4j +@JsonIgnoreProperties(ignoreUnknown = true) public class ZoomWebhookEvent { + @NonNull @JsonProperty("event") - private String event; + private String eventName; @JsonProperty("event_ts") private Long eventTimestamp; + @NonNull @JsonProperty("payload") private WebhookPayload payload; @Getter @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class WebhookPayload { @JsonProperty("account_id") @@ -29,10 +35,14 @@ public static class WebhookPayload { @JsonProperty("object") private WebhookObject object; + + @JsonProperty("plainToken") + private String plainToken; // URL 검증 요청 시 사용 } @Getter @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class WebhookObject { @JsonProperty("uuid") @@ -65,6 +75,7 @@ public static class WebhookObject { @Getter @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) public static class WebhookParticipant { @JsonProperty("user_id") @@ -90,5 +101,11 @@ public static class WebhookParticipant { @JsonProperty("leave_reason") private String leaveReason; + + @JsonProperty("public_ip") + private String publicIp; + + @JsonProperty("participant_user_id") + private String participantUserId; } } diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookValidationResponse.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookValidationResponse.java new file mode 100644 index 00000000..f0cef2c5 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/dto/ZoomWebhookValidationResponse.java @@ -0,0 +1,20 @@ +package backend.techeerzip.infra.zoom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** Zoom 웹훅 URL 검증 응답 DTO */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ZoomWebhookValidationResponse { + + @JsonProperty("plainToken") + private String plainToken; + + @JsonProperty("encryptedToken") + private String encryptedToken; +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomException.java new file mode 100644 index 00000000..e15e633e --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomException.java @@ -0,0 +1,16 @@ +package backend.techeerzip.infra.zoom.exception; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.global.exception.InfraException; + +/** Zoom 관련 인프라 예외 */ +public class ZoomException extends InfraException { + + public ZoomException(ErrorCode errorCode) { + super(errorCode); + } + + public ZoomException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookInvalidAuthenticationException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookInvalidAuthenticationException.java new file mode 100644 index 00000000..e39caf6b --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookInvalidAuthenticationException.java @@ -0,0 +1,21 @@ +package backend.techeerzip.infra.zoom.exception; + +import org.springframework.security.core.AuthenticationException; + +import backend.techeerzip.global.exception.ErrorCode; + +/** + * Zoom Webhook 인증 실패 예외 AuthenticationException을 상속하여 Spring Security의 exception handling에서 처리되도록 함 + */ +public class ZoomWebhookInvalidAuthenticationException extends AuthenticationException { + + private static final ErrorCode ERROR_CODE = ErrorCode.ZOOM_WEBHOOK_AUTHENTICATION_FAILED; + + public ZoomWebhookInvalidAuthenticationException() { + super(ERROR_CODE.getMessage()); + } + + public ErrorCode getErrorCode() { + return ERROR_CODE; + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookPlainTokenException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookPlainTokenException.java new file mode 100644 index 00000000..b0e48769 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookPlainTokenException.java @@ -0,0 +1,12 @@ +package backend.techeerzip.infra.zoom.exception; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.global.exception.InfraException; + +/** Zoom Webhook 처리 중 발생하는 예외 */ +public class ZoomWebhookPlainTokenException extends InfraException { + + public ZoomWebhookPlainTokenException() { + super(ErrorCode.ZOOM_WEBHOOK_PLAIN_TOKEN_MISSING); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookProcessingException.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookProcessingException.java new file mode 100644 index 00000000..122dbad7 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/exception/ZoomWebhookProcessingException.java @@ -0,0 +1,12 @@ +package backend.techeerzip.infra.zoom.exception; + +import backend.techeerzip.global.exception.ErrorCode; +import backend.techeerzip.global.exception.InfraException; + +/** Zoom Webhook 처리 중 발생하는 예외 */ +public class ZoomWebhookProcessingException extends InfraException { + + public ZoomWebhookProcessingException(Throwable cause) { + super(ErrorCode.ZOOM_WEBHOOK_PROCESSING_FAILED, cause); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomLeaveReason.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomLeaveReason.java new file mode 100644 index 00000000..d542b9bf --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomLeaveReason.java @@ -0,0 +1,79 @@ +package backend.techeerzip.infra.zoom.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Zoom 참가자 퇴장 사유 완전히 나간 케이스만 정의 (소회의실 이동 등은 제외) */ +@Getter +@RequiredArgsConstructor +public enum ZoomLeaveReason { + /** 참가자가 미팅을 나감 */ + LEFT_MEETING("left the meeting"), + + /** 참가자가 연결이 끊김 */ + DISCONNECTED("got disconnected from the meeting"), + + /** 호스트가 미팅을 종료 */ + HOST_ENDED("Host ended the meeting"), + + /** 호스트가 미팅을 닫음 */ + HOST_CLOSED("Host closed the meeting"), + + /** 호스트가 새 미팅을 시작 */ + HOST_STARTED_NEW("Host started a new meeting"), + + /** 네트워크 연결 오류 */ + NETWORK_ERROR("Network connection error"), + + /** 무료 미팅 시간 한도 초과 */ + EXCEEDED_FREE_LIMIT("Exceeded free meeting minutes limit"), + + /** 호스트가 참가자를 제거 */ + REMOVED_BY_HOST("Removed by host"), + + /** 알 수 없는 이유 */ + UNKNOWN("Unknown reason"); + + private final String reasonText; + + /** + * 주어진 leaveReason 문자열이 완전히 나간 케이스인지 확인 + * + * @param leaveReason Zoom에서 받은 leaveReason 문자열 (null 가능) + * @return 완전히 나간 케이스면 true, 그 외(소회의실 이동, 대기실 관련 등)는 false + */ + public static boolean isCompleteExit(String leaveReason) { + if (leaveReason == null || leaveReason.isBlank()) { + return false; + } + + String lowerReason = leaveReason.toLowerCase(); + + // 소회의실 관련 이벤트는 제외 + if (lowerReason.contains("breakout room") + || lowerReason.contains("join breakout") + || lowerReason.contains("leave breakout")) { + return false; + } + + // 대기실 관련 이벤트는 제외 + if (lowerReason.contains("waiting room")) { + return false; + } + + // 호스트가 참여하지 않은 경우는 제외 (퇴장이 아님) + if (lowerReason.contains("host did not join")) { + return false; + } + + // 완전히 나간 케이스 체크 + for (ZoomLeaveReason reason : values()) { + if (lowerReason.contains(reason.reasonText.toLowerCase())) { + return true; + } + } + + // 명시적으로 정의된 케이스가 아니면 false 반환 (안전하게 처리) + return false; + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java new file mode 100644 index 00000000..d609a5ee --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/type/ZoomWebhookEventType.java @@ -0,0 +1,28 @@ +package backend.techeerzip.infra.zoom.type; + +import lombok.Getter; + +@Getter +public enum ZoomWebhookEventType { + URL_VALIDATION("endpoint.url_validation"), + PARTICIPANT_JOINED("meeting.participant_joined"), + PARTICIPANT_LEFT("meeting.participant_left"); + + private final String eventName; + + ZoomWebhookEventType(String eventName) { + this.eventName = eventName; + } + + public static boolean isUrlValidation(String eventName) { + return URL_VALIDATION.getEventName().equals(eventName); + } + + public static boolean isParticipantJoined(String eventName) { + return PARTICIPANT_JOINED.getEventName().equals(eventName); + } + + public static boolean isParticipantLeft(String eventName) { + return PARTICIPANT_LEFT.getEventName().equals(eventName); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java new file mode 100644 index 00000000..828be562 --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookController.java @@ -0,0 +1,36 @@ +package backend.techeerzip.infra.zoom.webhook; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import backend.techeerzip.infra.zoom.config.ZoomAuth; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookValidationResponse; +import backend.techeerzip.infra.zoom.type.ZoomWebhookEventType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class ZoomWebhookController { + + private final ZoomWebhookService webhookService; + + /** Zoom Webhook 이벤트 수신 */ + @PostMapping("/api/v3/zoom/webhook/events") + @ZoomAuth + public ResponseEntity handleWebhookEvent( + @RequestBody ZoomWebhookEvent event) { + + log.info("Zoom Webhook 이벤트 수신 - event: {}", event.getEventName()); + + // URL 검증 요청인 경우 별도 처리 (토큰을 포함한 JSON 응답 반환) + if (ZoomWebhookEventType.isUrlValidation(event.getEventName())) { + return ResponseEntity.ok(webhookService.handleUrlValidationRequest(event)); + } + return ResponseEntity.ok(webhookService.handleWebhookEvents(event)); + } +} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookHandler.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookHandler.java deleted file mode 100644 index 261ac3b2..00000000 --- a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookHandler.java +++ /dev/null @@ -1,135 +0,0 @@ -package backend.techeerzip.infra.zoom.webhook; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; - -import backend.techeerzip.domain.zoom.service.ZoomAttendanceService; -import backend.techeerzip.infra.zoom.config.ZoomApiConfig; -import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RestController -@RequiredArgsConstructor -public class ZoomWebhookHandler { - - private final ZoomApiConfig zoomApiConfig; - private final ZoomAttendanceService zoomAttendanceService; - - /** Zoom Webhook 이벤트 수신 */ - @PostMapping("/api/v3/zoom/webhook/events") - public ResponseEntity handleWebhookEvent( - @RequestHeader(value = "authorization", required = false) String authHeader, - @RequestBody ZoomWebhookEvent event) { - - try { - // Webhook 검증 - if (!isValidWebhook(authHeader)) { - log.warn("Invalid webhook authorization header"); - return ResponseEntity.status(401).body("Unauthorized"); - } - - // 이벤트 타입별 처리 - String eventType = event.getEvent(); - if (eventType == null) { - log.warn("Event type is null"); - return ResponseEntity.badRequest().body("Invalid event"); - } - - switch (eventType) { - case "meeting.participant_joined": - handleParticipantJoined(event); - break; - case "meeting.participant_left": - handleParticipantLeft(event); - break; - default: - log.debug("Unhandled event type: {}", eventType); - break; - } - - return ResponseEntity.ok("OK"); - - } catch (Exception e) { - log.error("웹훅 처리 오류: {}", e.getMessage(), e); - return ResponseEntity.status(500).body("Internal Server Error"); - } - } - - /** Webhook 인증 검증 */ - private boolean isValidWebhook(String authHeader) { - // Webhook verification token이 설정되지 않은 경우 - if (zoomApiConfig.getWebhookVerificationToken() == null - || zoomApiConfig.getWebhookVerificationToken().isEmpty()) { - log.warn("Webhook verification token not configured"); - return false; - } - - if (authHeader == null || authHeader.trim().isEmpty()) { - log.warn("Missing authorization header"); - return false; - } - - // Zoom은 Bearer 접두사 없이 토큰만 보냄 - String token = authHeader.trim(); - boolean isValid = zoomApiConfig.getWebhookVerificationToken().equals(token); - - if (!isValid) { - log.warn("Invalid webhook token"); - } - - return isValid; - } - - /** 참가자 입장 이벤트 처리 */ - private void handleParticipantJoined(ZoomWebhookEvent event) { - try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant != null) { - String participantUuid = participant.getParticipantUuid(); - - log.info( - "참가자 입장: {} (uuid: {}, 시간: {})", - participant.getUserName(), - participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음", - participant.getJoinTime() != null ? participant.getJoinTime() : "없음"); - - // 도메인 서비스 호출하여 출석 데이터 저장 - zoomAttendanceService.handleWebhookEvent(event); - } - } catch (Exception e) { - log.error("참가자 입장 이벤트 처리 오류: {}", e.getMessage(), e); - } - } - - /** 참가자 퇴장 이벤트 처리 */ - private void handleParticipantLeft(ZoomWebhookEvent event) { - try { - ZoomWebhookEvent.WebhookParticipant participant = - event.getPayload().getObject().getParticipant(); - - if (participant != null) { - String participantUuid = participant.getParticipantUuid(); - String leaveReason = participant.getLeaveReason(); - - log.info( - "참가자 퇴장: {} (uuid: {}, 시간: {}, 사유: {})", - participant.getUserName(), - participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음", - participant.getLeaveTime() != null ? participant.getLeaveTime() : "없음", - leaveReason != null ? leaveReason : "없음"); - - // 도메인 서비스 호출하여 출석 데이터 저장 - zoomAttendanceService.handleWebhookEvent(event); - } - } catch (Exception e) { - log.error("참가자 퇴장 이벤트 처리 오류: {}", e.getMessage(), e); - } - } -} diff --git a/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java new file mode 100644 index 00000000..c65d2bbc --- /dev/null +++ b/techeerzip/src/main/java/backend/techeerzip/infra/zoom/webhook/ZoomWebhookService.java @@ -0,0 +1,79 @@ +package backend.techeerzip.infra.zoom.webhook; + +import org.springframework.stereotype.Service; + +import backend.techeerzip.domain.zoom.service.ZoomAttendanceService; +import backend.techeerzip.infra.zoom.config.ZoomTokenProvider; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookEvent; +import backend.techeerzip.infra.zoom.dto.ZoomWebhookValidationResponse; +import backend.techeerzip.infra.zoom.exception.ZoomWebhookPlainTokenException; +import backend.techeerzip.infra.zoom.type.ZoomWebhookEventType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ZoomWebhookService { + private final ZoomAttendanceService zoomAttendanceService; + private final ZoomTokenProvider zoomTokenProvider; + + /** URL 검증 요청 처리 */ + public ZoomWebhookValidationResponse handleUrlValidationRequest(ZoomWebhookEvent event) { + String plainToken = event.getPayload().getPlainToken(); + + if (plainToken == null || plainToken.isBlank()) { + log.error("URL 검증 요청에 plainToken이 없습니다"); + throw new ZoomWebhookPlainTokenException(); + } + + log.info("Zoom Webhook URL 검증 요청 - plainToken: {}", plainToken); + + String encryptedToken = zoomTokenProvider.generateEncryptedToken(plainToken); + + log.info("Zoom Webhook URL 검증 성공"); + return new ZoomWebhookValidationResponse(plainToken, encryptedToken); + } + + public ZoomWebhookValidationResponse handleWebhookEvents(ZoomWebhookEvent event) { + ZoomWebhookEvent.WebhookParticipant participant = + event.getPayload().getObject().getParticipant(); + + String participantUuid = participant.getParticipantUuid(); + String participantUuidLogMsg = + participantUuid != null ? participantUuid.substring(0, 8) + "..." : "없음"; + + /* 참가자 입장 이벤트 처리 */ + if (ZoomWebhookEventType.isParticipantJoined(event.getEventName())) { + // 소회의실 입장 여부 확인을 위한 디버깅 로그 + log.debug( + "입장 이벤트 상세 - participantId: {}, participantUuid: {}, joinTime: {}", + participant.getParticipantId(), + participantUuidLogMsg, + participant.getJoinTime()); + + zoomAttendanceService.handleParticipantJoined(participant); + log.info( + "참가자 입장: {} (uuid: {}, 시간: {})", + participant.getUserName(), + participantUuidLogMsg, + participant.getJoinTime() != null ? participant.getJoinTime() : "없음"); + } + + /* 참가자 퇴장 이벤트 처리 */ + if (ZoomWebhookEventType.isParticipantLeft(event.getEventName())) { + String leaveReason = participant.getLeaveReason(); + + log.info( + "참가자 퇴장: {} (uuid: {}, 시간: {}, 사유: {})", + participant.getUserName(), + participantUuidLogMsg, + participant.getLeaveTime() != null ? participant.getLeaveTime() : "없음", + leaveReason != null ? leaveReason : "없음"); + + // 도메인 서비스 호출하여 출석 데이터 저장 + zoomAttendanceService.handleParticipantLeft(participant); + } + return new ZoomWebhookValidationResponse(null, null); + } +} diff --git a/techeerzip/src/main/resources/application.properties b/techeerzip/src/main/resources/application.properties index ea859e9c..ab888c7b 100644 --- a/techeerzip/src/main/resources/application.properties +++ b/techeerzip/src/main/resources/application.properties @@ -69,7 +69,7 @@ springdoc.swagger-ui.tryItOutEnabled=true swagger.username=${SWAGGER_USER} swagger.password=${SWAGGER_PASSWORD} -https.server.url=${HTTPS_SERVER_URL} +https.server.url=${HTTPS_SERVER_URL} staging.server.url=${STAGING_SERVER_URL} x.api.key=${X_API_KEY} @@ -83,6 +83,7 @@ slack.message-url=${SLACKBOT_MESSAGE_URL} slack.today-cs-id=${SLACK_CHANNEL_TODAY_CS_ID} slack.blog-challenge-id=${SLACK_CHANNEL_BLOG_CHALLENGE_ID} slack.emergency-alert-id=${SLACK_CHANNEL_EMERGENCY_ALERT_ID} +slack.resume-id=${SLACK_CHANNEL_RESUME_ID} # Flyway ?? spring.flyway.enabled=true diff --git a/techeerzip/src/test/java/backend/techeerzip/TecheerzipApplicationTests.java b/techeerzip/src/test/java/backend/techeerzip/TecheerzipApplicationTests.java index 0718bd4a..1d17e747 100644 --- a/techeerzip/src/test/java/backend/techeerzip/TecheerzipApplicationTests.java +++ b/techeerzip/src/test/java/backend/techeerzip/TecheerzipApplicationTests.java @@ -1,22 +1,15 @@ package backend.techeerzip; +import static backend.techeerzip.config.SharedTestContainer.POSTGRES; + import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ActiveProfiles; -import backend.techeerzip.infra.googleDrive.service.GoogleDriveService; -import backend.techeerzip.infra.redis.service.RedisService; -import backend.techeerzip.infra.s3.S3Service; +import backend.techeerzip.config.IntegrationTestSupport; -@SpringBootTest -@ActiveProfiles("test") -class TecheerzipApplicationTests { - @MockBean private S3Service s3Service; - // @MockBean private SlackEventHandler slackEventHandler; - @MockBean private GoogleDriveService googleDriveService; - @MockBean private RedisService redisService; +class TecheerzipApplicationTests extends IntegrationTestSupport { @Test - void contextLoads() {} + void contextLoads() { + System.out.println("연결된 DB URL: " + POSTGRES.getJdbcUrl()); + } } diff --git a/techeerzip/src/test/java/backend/techeerzip/config/IntegrationTestSupport.java b/techeerzip/src/test/java/backend/techeerzip/config/IntegrationTestSupport.java new file mode 100644 index 00000000..724ed67f --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/config/IntegrationTestSupport.java @@ -0,0 +1,36 @@ +package backend.techeerzip.config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * 통합 테스트를 위한 기본 지원 클래스. + * + *

이 클래스를 상속받는 테스트는 다음을 자동으로 제공받습니다: + * + *

    + *
  • PostgreSQL Testcontainers 설정 및 자동 연결 + *
  • 외부 서비스(Slack, S3, Redis, GoogleDrive) MockBean 설정 + *
  • test 프로파일 활성화 + *
+ */ +@SpringBootTest +@ActiveProfiles("test") +@Import(TestExternalServiceConfig.class) +public abstract class IntegrationTestSupport { + + // 1. SharedTestContainer에서 정의한 컨테이너를 가져옴 + // SharedTestContainer의 static initializer에서 이미 시작됨 + static final PostgreSQLContainer POSTGRES = SharedTestContainer.POSTGRES; + + // 2. @ServiceConnection: Spring Boot가 알아서 이 컨테이너의 정보를 + // spring.datasource.url, username, password 등에 꽂아넣습니다. + // Flyway, JPA, R2DBC 전부 자동으로 설정됩니다. + @ServiceConnection static final PostgreSQLContainer connection = POSTGRES; + + // @BeforeAll 제거: SharedTestContainer의 static initializer에서 이미 컨테이너가 시작됨 + // 클래스 로딩 시점에 한 번만 실행되므로 동기화 오버헤드 없이 효율적 +} diff --git a/techeerzip/src/test/java/backend/techeerzip/config/RepositoryTestSupport.java b/techeerzip/src/test/java/backend/techeerzip/config/RepositoryTestSupport.java new file mode 100644 index 00000000..17c0356b --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/config/RepositoryTestSupport.java @@ -0,0 +1,17 @@ +package backend.techeerzip.config; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; + +@ActiveProfiles("test") +public abstract class RepositoryTestSupport { + // 1. 컨테이너 정의 (static final) + // SharedTestContainer의 static initializer에서 이미 시작됨 + static final PostgreSQLContainer POSTGRES = SharedTestContainer.POSTGRES; + + @ServiceConnection static final PostgreSQLContainer connection = POSTGRES; + + // @BeforeAll 제거: SharedTestContainer의 static initializer에서 이미 컨테이너가 시작됨 + // 클래스 로딩 시점에 한 번만 실행되므로 동기화 오버헤드 없이 효율적 +} diff --git a/techeerzip/src/test/java/backend/techeerzip/config/SharedTestContainer.java b/techeerzip/src/test/java/backend/techeerzip/config/SharedTestContainer.java new file mode 100644 index 00000000..5a1580f1 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/config/SharedTestContainer.java @@ -0,0 +1,23 @@ +package backend.techeerzip.config; + +import org.testcontainers.containers.PostgreSQLContainer; + +public final class SharedTestContainer { + + // 인스턴스화 방지 (private 생성자) + private SharedTestContainer() { + throw new UnsupportedOperationException("이 클래스는 인스턴스를 생성할 수 없습니다."); + } + + // static final 필드로 싱글톤 컨테이너 관리 + public static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:14-alpine").withReuse(true); + + // static initializer: 클래스 로딩 시점에 한 번만 실행됨 + // JVM의 클래스 로딩은 동기화되므로 별도의 synchronized 불필요 + static { + if (!POSTGRES.isRunning()) { + POSTGRES.start(); + } + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/config/SyncTestConfig.java b/techeerzip/src/test/java/backend/techeerzip/config/SyncTestConfig.java index e872fab4..1397278c 100644 --- a/techeerzip/src/test/java/backend/techeerzip/config/SyncTestConfig.java +++ b/techeerzip/src/test/java/backend/techeerzip/config/SyncTestConfig.java @@ -9,7 +9,7 @@ @TestConfiguration public class SyncTestConfig { - @Bean(name = "defaultExecutor") + @Bean(name = "testExecutor") public Executor taskExecutor() { return new SyncTaskExecutor(); } diff --git a/techeerzip/src/test/java/backend/techeerzip/config/TestExternalServiceConfig.java b/techeerzip/src/test/java/backend/techeerzip/config/TestExternalServiceConfig.java new file mode 100644 index 00000000..e583f09e --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/config/TestExternalServiceConfig.java @@ -0,0 +1,63 @@ +package backend.techeerzip.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +import backend.techeerzip.infra.googleDrive.service.GoogleDriveService; +import backend.techeerzip.infra.redis.service.RedisService; +import backend.techeerzip.infra.s3.S3Service; +import backend.techeerzip.infra.slack.config.SlackProperties; +import backend.techeerzip.infra.slack.event.SlackEventHandler; +import backend.techeerzip.infra.slack.service.SlackService; +import backend.techeerzip.infra.slack.util.HttpClient; + +/** + * 테스트 환경에서 외부 서비스(Slack, S3, Redis, GoogleDrive 등)를 MockBean으로 대체하는 설정 클래스. + * + *

이 설정은 test 프로파일에서만 활성화되며, 외부 서비스와의 실제 연결 없이 테스트를 수행할 수 있도록 합니다. + * + *

@PostConstruct로 초기화 검증을 수행하는 서비스들(SlackService, RedisService, GoogleDriveService)을 MockBean으로 + * 대체하여 테스트 환경에서 발생하는 초기화 오류를 방지합니다. + */ +@TestConfiguration +@Profile("test") +public class TestExternalServiceConfig { + + @MockBean private S3Service s3Service; + + @MockBean private SlackService slackService; + + @MockBean private SlackEventHandler slackEventHandler; + + @MockBean private SlackProperties slackProperties; + + @MockBean private HttpClient httpClient; + + @MockBean private GoogleDriveService googleDriveService; + + @MockBean private RedisService redisService; + + /** + * 테스트 환경에서 Redis 연결을 시도하지 않도록 MockBean으로 제공. RedisConfig가 @Profile("!test")로 비활성화되므로, 필요한 빈들을 + * 여기서 제공합니다. + */ + @MockBean private RedisConnectionFactory redisConnectionFactory; + + @MockBean private RedisTemplate redisTemplate; + + @MockBean private RedisMessageListenerContainer redisMessageListenerContainer; + + /** + * Spring Boot의 RedisReactiveAutoConfiguration이 필요로 하는 빈들. ReactiveRedisTemplate을 생성하기 위해 + * ReactiveRedisConnectionFactory가 필요합니다. + */ + @MockBean private ReactiveRedisConnectionFactory reactiveRedisConnectionFactory; + + @MockBean private ReactiveRedisTemplate reactiveRedisTemplate; +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/controller/BootcampControllerTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/controller/BootcampControllerTest.java new file mode 100644 index 00000000..43ad71d9 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/controller/BootcampControllerTest.java @@ -0,0 +1,257 @@ +package backend.techeerzip.domain.bootcamp.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.techeerzip.domain.bootcamp.dto.request.BootcampCreateRequest; +import backend.techeerzip.domain.bootcamp.dto.request.BootcampRankUpdateRequest; +import backend.techeerzip.domain.bootcamp.dto.request.BootcampVisibilityUpdateRequest; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampListResponse; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampRankUpdateResponse; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampResponse; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampVisibilityUpdateResponse; +import backend.techeerzip.domain.bootcamp.service.BootcampFacadeService; +import backend.techeerzip.domain.bootcamp.service.BootcampService; + +@WebMvcTest(BootcampController.class) +class BootcampControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean private BootcampService bootcampService; + @MockBean private BootcampFacadeService bootcampFacadeService; + + // 공통 URL 상수 + private static final String BASE_URL = "/api/v3/bootcamps"; + + @Test + @DisplayName("Guest용 부트캠프 리스트 조회 성공 (200 OK)") + @WithMockUser + void getBootcampListForGuestSuccess() throws Exception { + // given + BootcampListResponse response = BootcampListResponse.builder().hasNext(true).build(); + given(bootcampService.getBootcampList(any(), any(), any(), any(), any(), any())) + .willReturn(response); + + // when & then + // [수정] URL 경로 수정: /guest 제거 -> 기본 경로 사용 + mockMvc.perform( + get(BASE_URL) + .param("isAward", "true") + .param("year", "2025") + .param("limit", "10") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasNext").value(true)) + .andDo(print()); + } + + @Test + @DisplayName("부트캠프 생성 성공 (201 Created)") + @WithMockUser(roles = "ADMIN") + void createBootcampSuccess() throws Exception { + // given + BootcampCreateRequest.BootcampMemberRequest memberReq = + BootcampCreateRequest.BootcampMemberRequest.builder() + .userId(1L) + .position( + backend.techeerzip.domain.bootcampMember.entity.BootcampPosition.BE) + .isLeader(true) + .build(); + + BootcampCreateRequest request = + BootcampCreateRequest.builder() + .name("New Bootcamp") + .team("A") + .projectExplain("프로젝트 설명입니다.") // @NotBlank 조건 충족 + .members(java.util.List.of(memberReq)) // @NotNull 조건 충족 + .build(); + + String jsonContent = objectMapper.writeValueAsString(request); + MockMultipartFile requestPart = + new MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + jsonContent.getBytes(StandardCharsets.UTF_8)); + + MockMultipartFile imageFile = + new MockMultipartFile( + "image", "image.jpg", MediaType.IMAGE_JPEG_VALUE, "dummy image".getBytes()); + + BootcampResponse response = BootcampResponse.builder().name("New Bootcamp").build(); + given(bootcampFacadeService.createBootcamp(any(), any(BootcampCreateRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + multipart(BASE_URL) + .file(imageFile) + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("New Bootcamp")) + .andDo(print()); + } + + @Test + @DisplayName("부트캠프 생성 실패 - 필수 입력값 누락 (400 Bad Request)") + @WithMockUser + void createBootcampFailValidation() throws Exception { + // given + BootcampCreateRequest invalidRequest = BootcampCreateRequest.builder().build(); + + String jsonContent = objectMapper.writeValueAsString(invalidRequest); + MockMultipartFile requestPart = + new MockMultipartFile( + "request", + "", + MediaType.APPLICATION_JSON_VALUE, + jsonContent.getBytes(StandardCharsets.UTF_8)); + MockMultipartFile imageFile = + new MockMultipartFile( + "image", "img.jpg", MediaType.IMAGE_JPEG_VALUE, "content".getBytes()); + + // when & then + mockMvc.perform(multipart(BASE_URL).file(imageFile).file(requestPart).with(csrf())) + .andExpect(status().isBadRequest()) // 400 기대 (만약 DTO에 @NotNull 등이 없다면 201이 뜰 수 있음) + .andDo(print()); + } + + @Test + @DisplayName("부트캠프 수정 성공 (200 OK)") + @WithMockUser + void updateBootcampSuccess() throws Exception { + // given + Long bootcampId = 1L; + + BootcampCreateRequest.BootcampMemberRequest memberReq = + BootcampCreateRequest.BootcampMemberRequest.builder() + .userId(1L) + .position( + backend.techeerzip.domain.bootcampMember.entity.BootcampPosition.FE) + .isLeader(false) + .build(); + + BootcampCreateRequest updateRequest = + BootcampCreateRequest.builder() + .name("Updated") + .team("Updated Team") + .projectExplain("Updated Description") + .members(java.util.List.of(memberReq)) + .build(); + + MockMultipartFile requestPart = + new MockMultipartFile( + "request", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper + .writeValueAsString(updateRequest) + .getBytes(StandardCharsets.UTF_8)); + MockMultipartFile imageFile = + new MockMultipartFile( + "image", "new.jpg", MediaType.IMAGE_JPEG_VALUE, "new content".getBytes()); + + given(bootcampFacadeService.updateBootcamp(eq(bootcampId), any(), any())) + .willReturn(BootcampResponse.builder().name("Updated").build()); + + // when & then + mockMvc.perform( + multipart(HttpMethod.PUT, BASE_URL + "/{bootcampId}", bootcampId) + .file(imageFile) + .file(requestPart) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated")) + .andDo(print()); + } + + @Test + @DisplayName("부트캠프 삭제 성공 (204 No Content)") + @WithMockUser + void deleteBootcampSuccess() throws Exception { + // given + Long bootcampId = 1L; + doNothing().when(bootcampService).deleteBootcamp(bootcampId); + + // when & then + mockMvc.perform(delete(BASE_URL + "/{bootcampId}", bootcampId).with(csrf())) + .andExpect(status().isNoContent()) + .andDo(print()); + + verify(bootcampService).deleteBootcamp(bootcampId); + } + + @Test + @DisplayName("어드민 - 부트캠프 공개 여부 변경 성공 (200 OK)") + @WithMockUser(roles = "ADMIN") + void updateVisibilityByYearSuccess() throws Exception { + // given + int year = 2025; + BootcampVisibilityUpdateRequest request = new BootcampVisibilityUpdateRequest(true); + BootcampVisibilityUpdateResponse response = + BootcampVisibilityUpdateResponse.builder().updatedCount(5).build(); + + given(bootcampService.updateVisibilityByYear(eq(year), any())).willReturn(response); + + // when & then + mockMvc.perform( + patch(BASE_URL + "/admin/close") + .param("year", String.valueOf(year)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updatedCount").value(5)); + } + + @Test + @DisplayName("어드민 - 부트캠프 순위 변경 성공 (200 OK)") + @WithMockUser(roles = "ADMIN") + void updateBootcampRankSuccess() throws Exception { + // given + Long bootcampId = 1L; + BootcampRankUpdateRequest request = new BootcampRankUpdateRequest(1); + BootcampRankUpdateResponse response = BootcampRankUpdateResponse.builder().rank(1).build(); + + given(bootcampService.updateBootcampRank(eq(bootcampId), any())).willReturn(response); + + // when & then + mockMvc.perform( + patch(BASE_URL + "/admin/{bootcampId}/rank", bootcampId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rank").value(1)); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/entity/BootcampGenerationTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/entity/BootcampGenerationTest.java new file mode 100644 index 00000000..7fc2c784 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/entity/BootcampGenerationTest.java @@ -0,0 +1,93 @@ +package backend.techeerzip.domain.bootcamp.entity; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import backend.techeerzip.domain.bootcamp.exception.InvalidBootcampCreationPeriodException; + +/** + * 부트캠프 세대 계산 및 기간 관리 + * + *

기준: 2025년 하계 = 10기 + * + *

세대 계산 예시: - 2025년 6-8월 (하계) = 10기 - 2025년 12월-2026년 2월 (동계) = 11기 - 2026년 6-8월 (하계) = 12기 + */ +class BootcampGenerationTest { + + /** + * 2년치(2025~2026) 데이터를 기반으로 기수 계산 로직 검증 시나리오: - 2025 1~2월: 전년도(2024) 동계로 취급 -> 9기 (10기 - 1) - + * 2025 6~8월: 기준 기수 -> 10기 - 2025 12월 ~ 2026 2월: 2025 동계 -> 11기 - 2026 6~8월: 2026 하계 -> 12기 + */ + @ParameterizedTest(name = "{0}은 {1}기여야 한다") + @MethodSource("provideDatesForGeneration") + @DisplayName("날짜별 부트캠프 기수 계산이 정확해야 한다") + void calculateBootcampGenerationTest(LocalDate date, int expectedGeneration) { + // When + int result = BootcampGeneration.calculateBootcampGeneration(date); + + // Then + assertThat(result).isEqualTo(expectedGeneration); + } + + private static Stream provideDatesForGeneration() { + return Stream.of( + // 1. 2025년 초 (전년도 겨울 취급) -> 9기 + Arguments.of(LocalDate.of(2025, 1, 15), 9), + Arguments.of(LocalDate.of(2025, 2, 28), 9), + + // 2. 2025년 하계 (기준) -> 10기 + Arguments.of(LocalDate.of(2025, 6, 1), 10), + Arguments.of(LocalDate.of(2025, 7, 15), 10), + Arguments.of(LocalDate.of(2025, 8, 31), 10), + + // 3. 2025년 동계 (연말) -> 11기 + Arguments.of(LocalDate.of(2025, 12, 1), 11), + + // 4. 2026년 동계 (연초, 해가 바뀌었지만 11기 유지) -> 11기 + Arguments.of(LocalDate.of(2026, 1, 1), 11), + Arguments.of(LocalDate.of(2026, 2, 28), 11), + + // 5. 2026년 하계 -> 12기 + Arguments.of(LocalDate.of(2026, 6, 15), 12), + + // 6. 2026년 동계 (연말) -> 13기 + Arguments.of(LocalDate.of(2026, 12, 31), 13)); + } + + @ParameterizedTest(name = "{0}은 생성 불가능한 기간이어야 한다") + @MethodSource("provideInvalidDates") + @DisplayName("하계(6-8월)/동계(12-2월) 외의 기간에는 예외가 발생해야 한다") + void validateCreationPeriodThrowExceptionTest(LocalDate date) { + assertThatThrownBy(() -> BootcampGeneration.validateCreationPeriod(date)) + .isInstanceOf(InvalidBootcampCreationPeriodException.class); + } + + @ParameterizedTest(name = "{0}은 생성 가능한 기간이어야 한다") + @MethodSource("provideDatesForGeneration") + void validateCreationPeriodSuccessTest(LocalDate date) { + // 예외가 발생하지 않아야 함 + Assertions.assertDoesNotThrow(() -> BootcampGeneration.validateCreationPeriod(date)); + } + + private static Stream provideInvalidDates() { + return Stream.of( + // 봄 (3, 4, 5월) + Arguments.of(LocalDate.of(2025, 3, 1)), + Arguments.of(LocalDate.of(2025, 4, 15)), + Arguments.of(LocalDate.of(2025, 5, 31)), + + // 가을 (9, 10, 11월) + Arguments.of(LocalDate.of(2025, 9, 1)), + Arguments.of(LocalDate.of(2025, 10, 15)), + Arguments.of(LocalDate.of(2025, 11, 30))); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/entity/BootcampTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/entity/BootcampTest.java new file mode 100644 index 00000000..509c49c3 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/entity/BootcampTest.java @@ -0,0 +1,80 @@ +package backend.techeerzip.domain.bootcamp.entity; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.bootcamp.helper.BootcampTestHelper; + +class BootcampTest { + + @Test + @DisplayName("빌더가 필수 필드를 설정하고 기본 상태를 유지한다") + void builderCreatesBootcampWithDefaults() { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("Infra", 2025, 3); + + assertAll( + () -> assertEquals("Infra", bootcamp.getName()), + () -> assertEquals("A", bootcamp.getTeam()), + () -> assertEquals(2025, bootcamp.getYear()), + () -> assertEquals(3, bootcamp.getRank()), + () -> assertTrue(bootcamp.getIsOpen()), + () -> assertFalse(bootcamp.getIsDeleted()), + () -> assertTrue(bootcamp.getMembers().isEmpty())); + } + + @Test + @DisplayName("update 호출 시 필드와 수정 시간이 갱신된다") + void updateShouldRefreshEditableFields() { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("AI", 2025, 1, true); + + bootcamp.update( + "New Name", + "New Team", + "New description", + "https://github.com/new", + "https://medium.com/new", + "https://example.com", + "https://image.com/logo.png", + 5, + false); + + assertAll( + () -> assertEquals("New Name", bootcamp.getName()), + () -> assertEquals("New Team", bootcamp.getTeam()), + () -> assertEquals("New description", bootcamp.getProjectExplain()), + () -> assertEquals("https://github.com/new", bootcamp.getGithubUrl()), + () -> assertEquals("https://medium.com/new", bootcamp.getMediumUrl()), + () -> assertEquals("https://example.com", bootcamp.getWebUrl()), + () -> assertEquals("https://image.com/logo.png", bootcamp.getImageUrl()), + () -> assertEquals(5, bootcamp.getRank()), + () -> assertFalse(bootcamp.getIsOpen()), + () -> assertNotNull(bootcamp.getUpdatedAt())); + } + + @Test + @DisplayName("softDelete 호출 시 삭제 상태와 수정 시간이 갱신된다") + void softDeleteMarksEntityDeleted() { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("BE", 2025, 2); + + bootcamp.softDelete(); + + assertAll( + () -> assertTrue(bootcamp.getIsDeleted()), + () -> assertNotNull(bootcamp.getUpdatedAt())); + } + + @Test + @DisplayName("멤버 추가 및 제거 시 Bootcamp.members 컬렉션이 동기화된다") + void addAndRemoveMembers() { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("FE", 2025, 1); + var member = BootcampTestHelper.createBootcampMember(bootcamp, "user1", true); + + bootcamp.addMember(member); + assertEquals(1, bootcamp.getMembers().size()); + + bootcamp.removeMember(member); + assertTrue(bootcamp.getMembers().isEmpty()); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/helper/BootcampTestHelper.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/helper/BootcampTestHelper.java new file mode 100644 index 00000000..ba4ee947 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/helper/BootcampTestHelper.java @@ -0,0 +1,105 @@ +package backend.techeerzip.domain.bootcamp.helper; + +import org.springframework.test.util.ReflectionTestUtils; + +import backend.techeerzip.domain.bootcamp.entity.Bootcamp; +import backend.techeerzip.domain.bootcampMember.entity.BootcampMember; +import backend.techeerzip.domain.bootcampMember.entity.BootcampPosition; +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.user.entity.User; + +/** 테스트용 Bootcamp 객체 생성을 돕는 헬퍼 클래스입니다. */ +public class BootcampTestHelper { + + private BootcampTestHelper() {} + + // ========================================================================= + // 1. Service Test 전용 메서드 (컴파일 에러 해결용) + // ========================================================================= + + /** + * [Service Test용] ID와 Rank만으로 빠르게 부트캠프를 생성합니다. ServiceTest 코드의 createBootcamp(1L, 3) 호출을 지원합니다. + * + * @param id 설정할 ID (Reflection 사용) + * @param rank 랭크 + * @return ID가 세팅된 Bootcamp 객체 + */ + public static Bootcamp createBootcamp(Long id, int rank) { + // 이름과 연도는 임의의 값으로 설정 (테스트에 영향 없도록) + Bootcamp bootcamp = createBootcamp("Bootcamp_" + id, 2025, rank, true); + ReflectionTestUtils.setField(bootcamp, "id", id); + return bootcamp; + } + + // ========================================================================= + // 2. Repository Test 및 공통 메서드 + // ========================================================================= + + /** 가장 일반적인 '활성화된(공개)' 부트캠프를 생성합니다. */ + public static Bootcamp createBootcamp(String name, int year, int rank) { + return createBootcamp(name, year, rank, true); + } + + /** 공개 여부(isOpen)를 지정하여 부트캠프를 생성합니다. (메인 빌더) */ + public static Bootcamp createBootcamp(String name, int year, int rank, boolean isOpen) { + return Bootcamp.builder() + .name(name) + .team("A") + .year(year) + .projectExplain("Description for " + name) + .rank(rank) + .isOpen(isOpen) + .build(); + } + + /** '삭제된' 상태의 부트캠프를 생성합니다. */ + public static Bootcamp createDeletedBootcamp(String name, int year, int rank) { + Bootcamp bootcamp = createBootcamp(name, year, rank, true); + bootcamp.softDelete(); + return bootcamp; + } + + /** ID, 이름, 연도, 랭크를 모두 지정하여 생성 (커서 페이징 등 상세 테스트용) */ + public static Bootcamp createBootcampWithId(Long id, String name, int year, int rank) { + Bootcamp bootcamp = createBootcamp(name, year, rank, true); + ReflectionTestUtils.setField(bootcamp, "id", id); + return bootcamp; + } + + /** BootcampMember를 빠르게 생성합니다. position은 기본적으로 BE로 설정됩니다. */ + public static BootcampMember createBootcampMember( + Bootcamp bootcamp, String suffix, boolean isLeader) { + return BootcampMember.builder() + .bootcamp(bootcamp) + .user(createUser(suffix)) + .position(BootcampPosition.BE) + .isLeader(isLeader) + .build(); + } + + /** 테스트용 User 엔티티를 생성합니다. */ + public static User createUser(String suffix) { + Role role = new Role("ROLE_USER"); + return User.builder() + .name("테스터" + suffix) + .email("tester" + suffix + "@example.com") + .nickname("tester" + suffix) + .year(2025) + .password("password") + .isLft(false) + .githubUrl("https://github.com/tester" + suffix) + .mainPosition("BE") + .subPosition("FE") + .school("Techeer") + .profileImage(null) + .isAuth(true) + .role(role) + .grade("A") + .mediumUrl(null) + .tistoryUrl(null) + .velogUrl(null) + .bootcampYear(2025) + .feedbackNotes("notes") + .build(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/repository/BootcampRepositoryTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/repository/BootcampRepositoryTest.java new file mode 100644 index 00000000..60c9f61a --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/repository/BootcampRepositoryTest.java @@ -0,0 +1,252 @@ +package backend.techeerzip.domain.bootcamp.repository; + +import static backend.techeerzip.domain.bootcamp.helper.BootcampTestHelper.*; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import backend.techeerzip.config.RepositoryTestSupport; +import backend.techeerzip.domain.bootcamp.entity.Bootcamp; +import backend.techeerzip.global.config.QueryDslConfig; + +@DataJpaTest +@Import(QueryDslConfig.class) +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class BootcampRepositoryTest extends RepositoryTestSupport { + + @Autowired private BootcampRepository bootcampRepository; + @Autowired private EntityManager entityManager; + + @BeforeEach + void setUp() { + bootcampRepository.deleteAll(); + entityManager.flush(); + entityManager.clear(); + } + + @Nested + @DisplayName("부트캠프 조회 (findBootcamps)") + class FindBootcampsTest { + + @Test + @DisplayName("성공: 삭제되지 않은 부트캠프만 조회한다") + @Transactional + void findBootcampsExcludesDeleted() { + // Given + Bootcamp active = createBootcamp("Active Bootcamp", 2025, 1); + Bootcamp deleted = createDeletedBootcamp("Deleted Bootcamp", 2025, 2); + + bootcampRepository.saveAll(List.of(active, deleted)); + clearPersistenceContext(); + + // When + var result = bootcampRepository.findBootcamps(2025, null, null, null, 10); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getName()).isEqualTo("Active Bootcamp"); + assertThat(result.getFirst().getIsDeleted()).isFalse(); + } + + @Test + @DisplayName("성공: 연도 필터링이 정상 동작한다") + @Transactional + void findBootcampsFilterByYear() { + // Given + Bootcamp bootcamp2024 = createBootcamp("2024 Bootcamp", 2024, 1); + Bootcamp bootcamp2025 = createBootcamp("2025 Bootcamp", 2025, 1); + + bootcampRepository.saveAll(List.of(bootcamp2024, bootcamp2025)); + clearPersistenceContext(); + + // When + var result = bootcampRepository.findBootcamps(2025, null, null, null, 10); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getYear()).isEqualTo(2025); + assertThat(result.getFirst().getName()).isEqualTo("2025 Bootcamp"); + } + + @Test + @DisplayName("성공: 수상 부트캠프만 조회한다 (rank 1~3)") + @Transactional + void findBootcampsFilterByAward() { + // Given + Bootcamp award1 = createBootcamp("1st Place", 2025, 1); + Bootcamp award2 = createBootcamp("2nd Place", 2025, 2); + Bootcamp award3 = createBootcamp("3rd Place", 2025, 3); + Bootcamp nonAward = createBootcamp("4th Place", 2025, 4); + + bootcampRepository.saveAll(List.of(award1, award2, award3, nonAward)); + clearPersistenceContext(); + + // When + var result = bootcampRepository.findBootcamps(2025, true, null, null, 10); + + // Then + assertThat(result).hasSize(3); + assertThat(result).extracting(Bootcamp::getRank).containsExactly(1, 2, 3); + } + + @Test + @DisplayName("성공: 커서 기반 페이징이 정상 동작한다") + @Transactional + void findBootcampsCursorPagination() { + // Given + Bootcamp b1 = createBootcamp("Bootcamp 1", 2025, 1); + Bootcamp b2 = createBootcamp("Bootcamp 2", 2025, 1); + Bootcamp b3 = createBootcamp("Bootcamp 3", 2025, 2); + + bootcampRepository.saveAll(List.of(b1, b2, b3)); + clearPersistenceContext(); + + // When - 첫 페이지 (limit 3, 전체 조회됨) + var firstPage = bootcampRepository.findBootcamps(2025, null, null, null, 3); + + // Then + assertThat(firstPage).hasSize(3); + } + + @Test + @DisplayName("성공: limit 개수만큼만 조회한다") + @Transactional + void findBootcampsRespectsLimit() { + // Given + for (int i = 1; i <= 5; i++) { + bootcampRepository.save(createBootcamp("Bootcamp " + i, 2025, 1)); + } + clearPersistenceContext(); + + // When + var result = bootcampRepository.findBootcamps(2025, null, null, null, 3); + + // Then + assertThat(result).hasSize(3); + } + + @Test + @DisplayName("성공: 정렬 순서가 올바르다 (rank 오름차순, id 오름차순)") + @Transactional + void findBootcampsCorrectOrdering() { + // Given + Bootcamp rank2 = createBootcamp("Rank 2", 2025, 2); + Bootcamp rank1Later = createBootcamp("Rank 1 Later", 2025, 1); + Bootcamp rank1First = createBootcamp("Rank 1 First", 2025, 1); + + // 저장 순서와 상관없이 정렬되는지 확인하기 위해 섞어서 저장 + bootcampRepository.save(rank2); + bootcampRepository.save(rank1Later); + bootcampRepository.save(rank1First); + clearPersistenceContext(); + + // When + var result = bootcampRepository.findBootcamps(2025, null, null, null, 10); + + // Then + assertThat(result).hasSize(3); + + // 1순위: Rank 오름차순 + assertThat(result.get(0).getRank()).isEqualTo(1); + assertThat(result.get(1).getRank()).isEqualTo(1); + assertThat(result.get(2).getRank()).isEqualTo(2); + + // 2순위: ID 오름차순 (Rank가 같을 때) + assertThat(result.get(0).getId()).isLessThan(result.get(1).getId()); + } + } + + @Nested + @DisplayName("부트캠프 공개 여부 일괄 변경 (updateVisibilityByYear)") + class UpdateVisibilityByYearTest { + + @Test + @DisplayName("성공: 특정 연도의 부트캠프 공개 여부를 일괄 변경한다") + @Transactional + void updateVisibilityByYearSuccess() { + // Given (isOpen = false로 초기화) + Bootcamp a2025 = createBootcamp("2025-1", 2025, 1, false); + Bootcamp b2025 = createBootcamp("2025-2", 2025, 2, false); + Bootcamp b2024 = createBootcamp("2024", 2024, 1, false); + + bootcampRepository.saveAll(List.of(a2025, b2025, b2024)); + clearPersistenceContext(); + + // When + int updatedCount = bootcampRepository.updateVisibilityByYear(2025, true); + clearPersistenceContext(); // 벌크 연산 후 영속성 컨텍스트 초기화 필수 + + // Then + assertThat(updatedCount).isEqualTo(2); + + List result2025 = + bootcampRepository.findAll().stream().filter(b -> b.getYear() == 2025).toList(); + + assertThat(result2025).hasSize(2).allMatch(Bootcamp::getIsOpen); + + Bootcamp result2024 = + bootcampRepository.findAll().stream() + .filter(b -> b.getYear() == 2024) + .findFirst() + .orElseThrow(); + assertThat(result2024.getIsOpen()).isFalse(); // 2024년은 그대로 false + } + + @Test + @DisplayName("성공: 삭제된 부트캠프는 변경되지 않는다") + @Transactional + void updateVisibilityByYearExcludesDeleted() { + // Given + Bootcamp active = createBootcamp("Active", 2025, 1, false); + Bootcamp deleted = createBootcamp("Deleted", 2025, 2, false); + deleted.softDelete(); // Helper 대신 명시적 호출도 가능하지만, Helper에 통합된 메서드 사용 추천 + + bootcampRepository.saveAll(List.of(active, deleted)); + clearPersistenceContext(); + + // When + int updatedCount = bootcampRepository.updateVisibilityByYear(2025, true); + clearPersistenceContext(); + + // Then + assertThat(updatedCount).isEqualTo(1); + + Bootcamp updatedActive = bootcampRepository.findById(active.getId()).orElseThrow(); + Bootcamp unchangedDeleted = bootcampRepository.findById(deleted.getId()).orElseThrow(); + + assertThat(updatedActive.getIsOpen()).isTrue(); + assertThat(unchangedDeleted.getIsOpen()).isFalse(); + } + + @Test + @DisplayName("성공: 변경할 부트캠프가 없으면 0을 반환한다") + @Transactional + void updateVisibilityByYearNoMatchingBootcamp() { + // When + int updatedCount = bootcampRepository.updateVisibilityByYear(2025, true); + + // Then + assertThat(updatedCount).isZero(); + } + } + + // 반복되는 flush/clear를 위한 유틸 메서드 + private void clearPersistenceContext() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImplTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImplTest.java new file mode 100644 index 00000000..121866d2 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/service/BootcampFacadeServiceImplTest.java @@ -0,0 +1,226 @@ +package backend.techeerzip.domain.bootcamp.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.nio.file.Path; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; + +import backend.techeerzip.domain.bootcamp.dto.request.BootcampCreateRequest; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampResponse; +import backend.techeerzip.domain.bootcamp.entity.Bootcamp; +import backend.techeerzip.global.converter.FileConverter; +import backend.techeerzip.global.logger.CustomLogger; +import backend.techeerzip.infra.s3.S3Service; + +@ExtendWith(MockitoExtension.class) +class BootcampFacadeServiceImplTest { + @Mock private S3Service s3Service; + @Mock private BootcampService bootcampService; + @Mock private CustomLogger logger; + + @Mock private Clock clock; + + @Mock private FileConverter fileConverter; + + @InjectMocks private BootcampFacadeServiceImpl bootcampFacadeService; + + // 테스트에서 공통으로 사용할 "성공하는 날짜" (2025년 6월 15일 = 10기 하계) + private static final LocalDate FIXED_DATE = LocalDate.of(2025, 6, 15); + + @Nested + @DisplayName("부트캠프 생성 (Create)") + class CreateBootcamp { + + @BeforeEach + void setUp() { + // 1. 고정된 시간을 가리키는 Clock 생성 + Clock fixedClock = + Clock.fixed( + FIXED_DATE.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault()); + + // 2. Mock Clock이 항상 위 시간을 반환하도록 설정 + // (lenient()를 붙이면, clock을 쓰지 않는 테스트 메서드에서도 에러가 나지 않습니다) + given(clock.instant()).willReturn(fixedClock.instant()); + given(clock.getZone()).willReturn(fixedClock.getZone()); + } + + @Test + @DisplayName("성공: 이미지를 S3에 업로드하고 DB에 저장한다") + void createBootcampSuccess() { + // Given + MultipartFile mockFile = mock(MultipartFile.class); + BootcampCreateRequest request = BootcampCreateRequest.builder().build(); + Path mockPath = Path.of("temp/image.jpg"); + String uploadedUrl = "https://s3.com/new-image.jpg"; + BootcampResponse expectedResponse = + BootcampResponse.builder().imageUrl(uploadedUrl).build(); + + // Mocking + given(fileConverter.prepareForUpload(mockFile)).willReturn(mockPath); + given(s3Service.uploadPath(eq(mockPath), anyString(), anyString())) + .willReturn(uploadedUrl); + given(bootcampService.createBootcamp(request, uploadedUrl)) + .willReturn(expectedResponse); + + // When + BootcampResponse response = bootcampFacadeService.createBootcamp(mockFile, request); + + // Then + assertThat(response.getImageUrl()).isEqualTo(uploadedUrl); + verify(s3Service).uploadPath(any(), anyString(), anyString()); // 업로드 호출 확인 + verify(bootcampService).createBootcamp(request, uploadedUrl); // DB 저장 호출 확인 + verify(s3Service, never()).deleteAsync(anyString()); // 롤백(삭제)은 호출되지 않아야 함 + } + + @Test + @DisplayName("실패(롤백): DB 저장 중 에러가 발생하면 업로드된 S3 이미지를 삭제해야 한다") + void createBootcampFailRollback() { + // Given + MultipartFile mockFile = mock(MultipartFile.class); + BootcampCreateRequest request = BootcampCreateRequest.builder().build(); + Path mockPath = Path.of("temp/image.jpg"); + String uploadedUrl = "https://s3.com/to-be-deleted.jpg"; + + given(fileConverter.prepareForUpload(mockFile)).willReturn(mockPath); + given(s3Service.uploadPath(any(), any(), any())).willReturn(uploadedUrl); + + // DB 저장 시 예외 발생 설정 + given(bootcampService.createBootcamp(request, uploadedUrl)) + .willThrow(new RuntimeException("DB Error")); + + // When & Then + assertThatThrownBy(() -> bootcampFacadeService.createBootcamp(mockFile, request)) + .isInstanceOf(RuntimeException.class); + + // 업로드된 URL에 대해 deleteAsync가 호출되었는지 확인 + verify(s3Service).deleteAsync(uploadedUrl); + } + } + + @Nested + @DisplayName("부트캠프 수정 (Update)") + class UpdateBootcamp { + + @Test + @DisplayName("성공(이미지 변경): 새 이미지를 업로드하고, DB 업데이트 후, 이전 이미지를 삭제한다") + void updateBootcampSuccessWithNewImage() { + // Given + Long bootcampId = 1L; + MultipartFile newImageFile = mock(MultipartFile.class); + BootcampCreateRequest request = BootcampCreateRequest.builder().build(); + + String oldUrl = "old-url"; + String newUrl = "new-url"; + Path mockPath = Path.of("temp/new.jpg"); + + // 기존 정보 조회 Mocking + Bootcamp mockBootcamp = Bootcamp.builder().imageUrl(oldUrl).build(); + given(bootcampService.findBootcampById(bootcampId)).willReturn(mockBootcamp); + + // 새 이미지 업로드 Mocking + given(newImageFile.isEmpty()).willReturn(false); + given(fileConverter.prepareForUpload(newImageFile)).willReturn(mockPath); + given(s3Service.uploadPath(any(), any(), any())).willReturn(newUrl); + + // 업데이트 수행 Mocking + BootcampResponse updatedResponse = BootcampResponse.builder().imageUrl(newUrl).build(); + given(bootcampService.updateBootcamp(bootcampId, request, newUrl)) + .willReturn(updatedResponse); + + // When + BootcampResponse result = + bootcampFacadeService.updateBootcamp(bootcampId, newImageFile, request); + + // Then + assertThat(result.getImageUrl()).isEqualTo(newUrl); + + // 1. 새 이미지 업로드 확인 + verify(s3Service).uploadPath(eq(mockPath), anyString(), anyString()); + + // 2. DB 업데이트 확인 + verify(bootcampService).updateBootcamp(bootcampId, request, newUrl); + + // 3. 이전 이미지 삭제 확인 (DB 업데이트 성공 후) + verify(s3Service).deleteAsync(oldUrl); + } + + @Test + @DisplayName("성공(이미지 미변경): 이미지가 없으면 업로드나 삭제 로직이 수행되지 않아야 한다") + void updateBootcampSuccessNoImageChange() { + // Given + Long bootcampId = 1L; + BootcampCreateRequest request = BootcampCreateRequest.builder().build(); + String oldUrl = "old-url"; + + Bootcamp mockBootcamp = Bootcamp.builder().imageUrl(oldUrl).build(); + given(bootcampService.findBootcampById(bootcampId)).willReturn(mockBootcamp); + + BootcampResponse response = BootcampResponse.builder().imageUrl(oldUrl).build(); + given(bootcampService.updateBootcamp(bootcampId, request, null)).willReturn(response); + + // When + bootcampFacadeService.updateBootcamp(bootcampId, null, request); + + // Then + verify(s3Service, never()).uploadPath(any(), any(), any()); + verify(s3Service, never()).deleteAsync(any()); + verify(bootcampService).updateBootcamp(bootcampId, request, null); + } + + @Test + @DisplayName("실패(롤백): 업데이트 중 에러 발생 시 새로 업로드한 이미지를 삭제해야 한다") + void updateBootcampFailRollback() { + // Given + Long bootcampId = 1L; + MultipartFile newImageFile = mock(MultipartFile.class); + BootcampCreateRequest request = BootcampCreateRequest.builder().build(); + String newUrl = "new-url"; + + Bootcamp mockBootcamp = Bootcamp.builder().imageUrl("old-url").build(); + given(bootcampService.findBootcampById(bootcampId)).willReturn(mockBootcamp); + + // 업로드 성공 가정 + given(newImageFile.isEmpty()).willReturn(false); + given(fileConverter.prepareForUpload(any())).willReturn(Path.of("tmp")); + given(s3Service.uploadPath(any(), any(), any())).willReturn(newUrl); + + // DB 업데이트 실패 가정 + given(bootcampService.updateBootcamp(bootcampId, request, newUrl)) + .willThrow(new RuntimeException("Update Failed")); + + // When & Then + assertThatThrownBy( + () -> + bootcampFacadeService.updateBootcamp( + bootcampId, newImageFile, request)) + .isInstanceOf(RuntimeException.class); + + // 새로 올린 이미지를 deleteMany로 삭제했는지 확인 + verify(s3Service).deleteMany(List.of(newUrl)); + // 기존 이미지는 삭제되면 안 됨 + verify(s3Service, never()).deleteAsync("old-url"); + } + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/service/BootcampServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/service/BootcampServiceTest.java new file mode 100644 index 00000000..1ebf5c5a --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcamp/service/BootcampServiceTest.java @@ -0,0 +1,448 @@ +package backend.techeerzip.domain.bootcamp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 backend.techeerzip.domain.bootcamp.dto.request.BootcampCreateRequest; +import backend.techeerzip.domain.bootcamp.dto.request.BootcampRankUpdateRequest; +import backend.techeerzip.domain.bootcamp.dto.request.BootcampVisibilityUpdateRequest; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampListResponse; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampRankUpdateResponse; +import backend.techeerzip.domain.bootcamp.dto.response.BootcampVisibilityUpdateResponse; +import backend.techeerzip.domain.bootcamp.entity.Bootcamp; +import backend.techeerzip.domain.bootcamp.entity.BootcampGeneration; +import backend.techeerzip.domain.bootcamp.exception.BootcampNotFoundException; +import backend.techeerzip.domain.bootcamp.helper.BootcampTestHelper; +import backend.techeerzip.domain.bootcamp.repository.BootcampRepository; +import backend.techeerzip.domain.bootcampMember.entity.BootcampMember; +import backend.techeerzip.domain.bootcampMember.entity.BootcampPosition; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.service.UserService; + +@ExtendWith(MockitoExtension.class) +class BootcampServiceTest { + @Mock private UserService userService; + @Mock private Clock clock; + @Mock private BootcampRepository bootcampRepository; + + @InjectMocks private BootcampService bootcampService; + + // 테스트에서 공통으로 사용할 "성공하는 날짜" (2025년 6월 15일 = 10기 하계) + private static final LocalDate FIXED_DATE = LocalDate.of(2025, 6, 15); + + private static Integer currentYear; + + @BeforeEach + void setUp() { + Clock fixedClock = + Clock.fixed( + FIXED_DATE.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault()); + + // [수정] lenient() 사용 + lenient().when(clock.instant()).thenReturn(fixedClock.instant()); + lenient().when(clock.getZone()).thenReturn(fixedClock.getZone()); + currentYear = BootcampGeneration.calculateBootcampGeneration(FIXED_DATE); + } + + @Test + @DisplayName("부트캠프 생성을 성공한다.") + void successCreateBootcamp() { + // Given + Long userId = 1L; + int currentGeneration = BootcampGeneration.calculateBootcampGeneration(FIXED_DATE); + BootcampCreateRequest.BootcampMemberRequest memberReq = + new BootcampCreateRequest.BootcampMemberRequest(1L, BootcampPosition.BE, true); + BootcampCreateRequest request = + BootcampCreateRequest.builder() + .name("woo") + .team("A") + .mediumUrl("mediumUrl") + .githubUrl("githubUrl") + .projectExplain("projectExplain") + .webUrl("webUrl") + .members(List.of(memberReq)) + .build(); + User mockUser = User.builder().build(); + ReflectionTestUtils.setField(mockUser, "id", userId); + ReflectionTestUtils.setField(mockUser, "bootcampYear", currentGeneration); + + given(userService.getIdAndUserMap(anyList(), any())).willReturn(Map.of(userId, mockUser)); + given(bootcampRepository.save(any(Bootcamp.class))).willReturn(Bootcamp.builder().build()); + + // When + bootcampService.createBootcamp(request, "imageUrl"); + + // Then + ArgumentCaptor bootcampCaptor = ArgumentCaptor.forClass(Bootcamp.class); + verify(bootcampRepository).save(bootcampCaptor.capture()); + + Bootcamp savedBootcamp = bootcampCaptor.getValue(); + + assertThat(savedBootcamp.getName()).isEqualTo("woo"); + assertThat(savedBootcamp.getMembers()).hasSize(1); + assertThat(savedBootcamp.getMembers().getFirst().getUser().getId()).isEqualTo(userId); + } + + @Test + @DisplayName("부트캠프 조회를 실패한다.") + void findBootcampByIdNotFoundThrowsException() { + given(bootcampRepository.findByIdAndIsDeletedFalse(any())).willReturn(Optional.empty()); + + assertThrows(BootcampNotFoundException.class, () -> bootcampService.findBootcampById(1L)); + } + + @Nested + @DisplayName("부트캠프 리스트 조회") + class GetBootcampsTest { + /* + * * 공통 + * * 조회된 리스트의 사이즈가 limit보다 큰 경우 + * * - limit만큼 부트캠프 리턴 + * * - hasNext true + * * - nextCursor 마지막 조회 id + * * - nextCursorRank 마지막 조회 rank + * * 사이즈가 limit보다 작은 경우 + * * - 사이즈만큼 리턴 + * * - hasNext false + * * - nextCursor null + * * - nextCursorRank null + * * 사이즈가 limit보다 같은 경우 + * * - hasNext false + * * - nextCursor null + * * - nextCursorRank null + * * user 조회 + * * - isParticipate true + * * guest 조회 + * * - isParticipate false + */ + + @Test + @DisplayName("부트캠프 리스트 유저 조회에서 hasNext가 true인지 검증한다.") + void successGetBootcampListWithUserId() { + // given + int size = 2; + Bootcamp bootcamp1 = BootcampTestHelper.createBootcamp(1L, 3); + Bootcamp bootcamp2 = BootcampTestHelper.createBootcamp(2L, 3); + Bootcamp bootcamp3 = BootcampTestHelper.createBootcamp(3L, 1); + + given(bootcampRepository.findBootcamps(any(), any(), any(), any(), any())) + .willReturn(List.of(bootcamp1, bootcamp2, bootcamp3)); + given(userService.isParticipate(1L, currentYear)).willReturn(true); + + BootcampListResponse response = + bootcampService.getBootcampListWithUserGeneration( + 1L, true, 2025, null, 1, size, currentYear); + + assertThat(response.getHasNext()).isTrue(); + assertThat(response.getNextCursor()).isEqualTo(3L); + assertThat(response.getNextCursorRank()).isEqualTo(1); + assertThat(response.getIsParticipate()).isTrue(); + assertThat(response.getData().getFirst().getId()).isEqualTo(1L); + } + + @Test + @DisplayName("회원 조회 - 조회된 사이즈가 limit보다 작거나 같으면(마지막 페이지) hasNext는 false여야 한다.") + void successGetBootcampListWithUserIdLastPage() { + // given + int limit = 3; // 3개를 요청 + Bootcamp b1 = BootcampTestHelper.createBootcamp(1L, 1); + Bootcamp b2 = BootcampTestHelper.createBootcamp(2L, 2); + + // DB에는 2개만 있어서 2개 반환 (limit + 1인 4개를 요청했지만 2개만 옴) + given(bootcampRepository.findBootcamps(any(), any(), any(), any(), eq(limit + 1))) + .willReturn(List.of(b1, b2)); + given(userService.isParticipate(anyLong(), any())).willReturn(false); + + // when + BootcampListResponse response = + bootcampService.getBootcampListWithUserGeneration( + 1L, true, 2025, null, null, limit, currentYear); + + // then + assertThat(response.getHasNext()).isFalse(); // 다음 페이지 없음 + assertThat(response.getNextCursor()).isNull(); // 커서 null + assertThat(response.getNextCursorRank()).isNull(); + assertThat(response.getData()).hasSize(2); // 데이터 개수 2개 + assertThat(response.getData().getLast().getId()).isEqualTo(2L); + } + + @Test + @DisplayName("Guest 조회 - hasNext가 true인 경우 (User 관련 로직이 실행되지 않아야 한다)") + void successGetBootcampListForGuestHasNext() { + // given + int limit = 2; + Bootcamp b1 = BootcampTestHelper.createBootcamp(1L, 1); + Bootcamp b2 = BootcampTestHelper.createBootcamp(2L, 2); + Bootcamp b3 = BootcampTestHelper.createBootcamp(3L, 3); // Overflow item + + // limit(2) + 1 = 3개 반환됨 + given(bootcampRepository.findBootcamps(any(), any(), any(), any(), eq(limit + 1))) + .willReturn(List.of(b1, b2, b3)); + + // when + BootcampListResponse response = + bootcampService.getBootcampListForGuest(true, 2025, null, null, limit); + + // then + assertThat(response.getHasNext()).isTrue(); + assertThat(response.getNextCursor()).isEqualTo(3L); // 3번째 아이템 ID + assertThat(response.getData()).hasSize(limit); // 반환 리스트는 2개로 잘려야 함 + + // Guest 조회이므로 회원 참여 여부를 확인하는 로직이 호출되면 안 됨 + verify(userService, never()).isParticipate(any(), any()); + + // Guest 응답에서 isParticipate 필드는 null이거나 false여야 함 (DTO 설계에 따라 다름) + assertThat(response.getIsParticipate()).isNull(); + } + + @Test + @DisplayName("조회된 데이터 개수가 limit와 정확히 일치할 때 hasNext는 false여야 한다.") + void successGetBootcampListExactMatch() { + // given + int limit = 2; + Bootcamp b1 = BootcampTestHelper.createBootcamp(1L, 1); + Bootcamp b2 = BootcampTestHelper.createBootcamp(2L, 2); + + // limit(2) + 1 = 3개를 요청했으나, DB에 딱 2개만 있어서 2개 반환됨 + given(bootcampRepository.findBootcamps(any(), any(), any(), any(), eq(limit + 1))) + .willReturn(List.of(b1, b2)); + + // when (Guest 조회로 테스트) + BootcampListResponse response = + bootcampService.getBootcampListForGuest(true, 2025, null, null, limit); + + // then + // list.size()(2) > size(2) 는 false여야 함 + assertThat(response.getHasNext()).isFalse(); + assertThat(response.getData()).hasSize(2); + assertThat(response.getNextCursor()).isNull(); + } + + @Test + @DisplayName("조회 결과가 아예 없는 경우(Empty) 빈 리스트를 반환하고 에러가 없어야 한다.") + void successGetBootcampListEmpty() { + // given + given(bootcampRepository.findBootcamps(any(), any(), any(), any(), anyInt())) + .willReturn(List.of()); // 빈 리스트 + + // when + BootcampListResponse response = + bootcampService.getBootcampListForGuest(true, 2025, null, null, 10); + + // then + assertThat(response.getData()).isEmpty(); + assertThat(response.getHasNext()).isFalse(); + assertThat(response.getNextCursor()).isNull(); + } + } + + /* + * 부트캠프 정보 수정 (Update) + * - 성공 케이스: + * - 기본 정보(이름, 설명, 링크 등)가 변경되어야 함 + * - 기존 멤버가 모두 지워지고(clear), 새로운 멤버로 교체되어야 함 + * - 이미지 URL이 주어지면 변경되고, null이면 기존 이미지를 유지해야 함 + * - 실패 케이스: + * - 존재하지 않는 ID 조회 시 예외 발생 (findBootcampById 검증) + */ + @Test + @DisplayName("부트캠프 수정 시 정보가 업데이트되고 멤버가 재설정되어야 한다") + void updateBootcampSuccess() { + // Given + Long bootcampId = 1L; + String newImageUrl = "new_image_url"; + int currentGeneration = BootcampGeneration.calculateBootcampGeneration(FIXED_DATE); + + // 1. Request 데이터 + BootcampCreateRequest.BootcampMemberRequest newMemberReq = + new BootcampCreateRequest.BootcampMemberRequest(2L, BootcampPosition.FE, false); + BootcampCreateRequest updateRequest = + BootcampCreateRequest.builder() + .name("Updated Name") + .team("Updated Team") + .projectExplain("Updated Explain") + .githubUrl("new_github") + .mediumUrl("new_medium") + .webUrl("new_web") + .members(List.of(newMemberReq)) + .build(); + + // 2. 기존 Bootcamp 객체 생성 (빌더 수정) + Bootcamp existingBootcamp = + Bootcamp.builder() + .name("Old Name") + .imageUrl("old_image") + .rank(10) + .year(2025) + .team("A") + .projectExplain("Old Explain") + .isOpen(true) + .build(); + ReflectionTestUtils.setField(existingBootcamp, "id", bootcampId); + + // 3. 기존 멤버 세팅 + User oldUser = User.builder().build(); + ReflectionTestUtils.setField(oldUser, "id", 1L); + BootcampMember oldMember = BootcampMember.builder().user(oldUser).build(); + + List membersList = new ArrayList<>(); + membersList.add(oldMember); + ReflectionTestUtils.setField(existingBootcamp, "members", membersList); + + // Mocking + given(bootcampRepository.findByIdAndIsDeletedFalse(bootcampId)) + .willReturn(Optional.of(existingBootcamp)); + + User newUser = User.builder().build(); + ReflectionTestUtils.setField(newUser, "id", 2L); + ReflectionTestUtils.setField(newUser, "bootcampYear", currentGeneration); + given(userService.getIdAndUserMap(anyList(), any())).willReturn(Map.of(2L, newUser)); + + // When + bootcampService.updateBootcamp(bootcampId, updateRequest, newImageUrl); + + // Then + assertThat(existingBootcamp.getName()).isEqualTo("Updated Name"); + assertThat(existingBootcamp.getImageUrl()).isEqualTo(newImageUrl); + + // 멤버 교체 확인 + assertThat(existingBootcamp.getMembers()).hasSize(1); + assertThat(existingBootcamp.getMembers().getFirst().getUser().getId()).isEqualTo(2L); + } + + @Test + @DisplayName("부트캠프 삭제 시 softDelete 상태로 변경되어야 한다") + void deleteBootcampSuccess() { + // Given + Long bootcampId = 1L; + + Bootcamp bootcamp = + Bootcamp.builder() + .name("To Delete") + .rank(1) + .year(2025) + .team("A") + .projectExplain("Desc") + .isOpen(true) + .build(); + + given(bootcampRepository.findByIdAndIsDeletedFalse(bootcampId)) + .willReturn(Optional.of(bootcamp)); + + // When + bootcampService.deleteBootcamp(bootcampId); + + // Then + assertThat(bootcamp.getIsDeleted()).isTrue(); + } + + /* + * 부트캠프 공개 여부 일괄 변경 (Admin) + * - 성공 케이스: + * - Repository의 updateVisibilityByYear 메서드가 올바른 인자로 호출되어야 함 + * - 변경된 개수(count)가 반환되어야 함 + */ + @Test + @DisplayName("특정 연도의 부트캠프 공개 여부를 일괄 변경해야 한다") + void updateVisibilityByYearSuccess() { + // Given + Integer year = 2025; + boolean isOpen = true; + BootcampVisibilityUpdateRequest request = new BootcampVisibilityUpdateRequest(isOpen); + + given(bootcampRepository.updateVisibilityByYear(year, isOpen)).willReturn(5); + + // When + BootcampVisibilityUpdateResponse response = + bootcampService.updateVisibilityByYear(year, request); + + // Then + assertThat(response.getUpdatedCount()).isEqualTo(5); + verify(bootcampRepository).updateVisibilityByYear(year, isOpen); + } + + /* + * 부트캠프 순위 변경 (Admin) + * - 성공 케이스: + * - 엔티티의 updateRank()가 호출되어 rank 값이 변경되어야 함 + */ + @Test + @DisplayName("부트캠프의 순위(Rank)를 변경할 수 있어야 한다") + void updateBootcampRankSuccess() { + // Given + Long bootcampId = 1L; + int newRank = 1; + BootcampRankUpdateRequest request = new BootcampRankUpdateRequest(newRank); + + Bootcamp bootcamp = Bootcamp.builder().rank(2).build(); + + given(bootcampRepository.findByIdAndIsDeletedFalse(bootcampId)) + .willReturn(Optional.of(bootcamp)); + + // When + BootcampRankUpdateResponse response = + bootcampService.updateBootcampRank(bootcampId, request); + + // Then + assertThat(bootcamp.getRank()).isEqualTo(newRank); + assertThat(response.getRank()).isEqualTo(newRank); + } + + /* + * 부트캠프 참여 여부 토글 (Toggle Participation) + * - 성공 케이스: + * - UserService의 toggleBootcampParticipation 메서드를 단순히 호출(Delegation)하는지 검증 + */ + @Test + @DisplayName("부트캠프 참여 여부 토글 기능은 유저 서비스로 위임되어야 한다") + void toggleBootcampParticipationSuccess() { + Clock fixedClock = + Clock.fixed( + FIXED_DATE.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault()); + + // 2. Mock Clock이 항상 위 시간을 반환하도록 설정 + // (lenient()를 붙이면, clock을 쓰지 않는 테스트 메서드에서도 에러가 나지 않습니다) + lenient().when(clock.instant()).thenReturn(fixedClock.instant()); + lenient().when(clock.getZone()).thenReturn(fixedClock.getZone()); + + Integer currentGen = bootcampService.getCurrentBootcampYear(); + // Given + Long userId = 1L; + + // When + bootcampService.toggleBootcampParticipation(userId); + + // Then + verify(userService).toggleBootcampParticipation(userId, currentGen); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/bootcampMember/entity/BootcampMemberTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/bootcampMember/entity/BootcampMemberTest.java new file mode 100644 index 00000000..ced432f3 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/bootcampMember/entity/BootcampMemberTest.java @@ -0,0 +1,67 @@ +package backend.techeerzip.domain.bootcampMember.entity; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.bootcamp.entity.Bootcamp; +import backend.techeerzip.domain.bootcamp.helper.BootcampTestHelper; + +class BootcampMemberTest { + + @Test + @DisplayName("빌더가 기본값과 연관관계를 설정한다") + void builderCreatesMemberWithDefaults() { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("Infra", 2025, 1); + var member = + BootcampMember.builder() + .bootcamp(bootcamp) + .user(BootcampTestHelper.createUser("1")) + .position(BootcampPosition.BE) + .isLeader(null) + .build(); + + assertAll( + () -> assertEquals(bootcamp, member.getBootcamp()), + () -> assertEquals("tester1@example.com", member.getUser().getEmail()), + () -> assertEquals(BootcampPosition.BE, member.getPosition()), + () -> assertFalse(member.isLeader()), + () -> assertFalse(member.getIsDeleted())); + } + + @Test + @DisplayName("softDelete 호출 시 삭제 상태와 수정 시간이 갱신된다") + void softDeleteMarksMemberDeleted() { + BootcampMember member = createMember("2"); + + member.softDelete(); + + assertAll( + () -> assertTrue(member.getIsDeleted()), + () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("updatePosition 호출 시 포지션이 변경된다") + void updatePositionChangesValue() { + BootcampMember member = createMember("3"); + + member.updatePosition(BootcampPosition.FE); + + assertEquals(BootcampPosition.FE, member.getPosition()); + } + + @Test + @DisplayName("리더 플래그 true 설정 시 isLeader가 true를 반환한다") + void leaderFlagRespected() { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("AI", 2025, 1); + var leader = BootcampTestHelper.createBootcampMember(bootcamp, "leader", true); + assertTrue(leader.isLeader()); + } + + private BootcampMember createMember(String suffix) { + Bootcamp bootcamp = BootcampTestHelper.createBootcamp("Bootcamp_" + suffix, 2025, 1); + return BootcampTestHelper.createBootcampMember(bootcamp, suffix, false); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/entity/ProjectMemberTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/entity/ProjectMemberTest.java index 7f09eaef..96112fe1 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/entity/ProjectMemberTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/entity/ProjectMemberTest.java @@ -1,58 +1,222 @@ -// package backend.techeerzip.domain.projectMember.entity; -// -// @SpringBootTest -// class ProjectMemberTest { -// private ProjectMember pm; -// -// @BeforeEach -// void setup() { -// final ProjectTeam mockPT = -// ProjectTeam.builder() -// .projectExplain("") -// .name("name") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build(); -// final User mockU = -// User.builder() -// .name("") -// .email("") -// .grade("") -// .isLft(true) -// .isAuth(true) -// .mainPosition("") -// .password("") -// .githubUrl("") -// .mediumUrl("") -// .profileImage("") -// .velogUrl("") -// .year(1) -// .role(new Role("")) -// .school("") -// .build(); -// pm = -// ProjectMember.builder() -// .projectTeam(mockPT) -// .teamRole(TeamRole.BACKEND) -// .status(StatusCategory.APPROVED) -// .summary("") -// .isLeader(true) -// .user(mockU) -// .build(); -// } -// -// @Test -// void entityTest() { -// Assertions.assertFalse(pm.isDeleted()); -// pm.softDelete(); -// Assertions.assertTrue(pm.isDeleted()); -// } -// } +package backend.techeerzip.domain.projectMember.entity; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.projectMember.helper.ProjectMemberTestHelper; +import backend.techeerzip.domain.projectTeam.type.TeamRole; +import backend.techeerzip.global.entity.StatusCategory; + +class ProjectMemberTest { + + @Test + @DisplayName("빌더가 필수 필드를 설정하고 기본 상태를 유지한다") + void builderCreatesProjectMemberWithDefaults() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + + assertAll( + () -> assertEquals(projectTeam, member.getProjectTeam()), + () -> assertEquals(user, member.getUser()), + () -> assertEquals(TeamRole.BACKEND, member.getTeamRole()), + () -> assertTrue(member.isLeader()), + () -> assertEquals(StatusCategory.APPROVED, member.getStatus()), + () -> assertFalse(member.isDeleted()), + () -> assertNotNull(member.getSummary())); + } + + @Test + @DisplayName("update 호출 시 필드와 수정 시간이 갱신된다") + void updateShouldRefreshEditableFields() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + + member.update(TeamRole.FRONTEND, StatusCategory.PENDING, false); + + assertAll( + () -> assertEquals(TeamRole.FRONTEND, member.getTeamRole()), + () -> assertEquals(StatusCategory.PENDING, member.getStatus()), + () -> assertFalse(member.isLeader()), + () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("softDelete 호출 시 삭제 상태와 수정 시간이 갱신된다") + void softDeleteMarksEntityDeleted() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + + member.softDelete(); + + assertAll(() -> assertTrue(member.isDeleted()), () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("toActive 호출 시 상태가 APPROVED로 변경되고 삭제 상태가 해제된다") + void toActiveChangesStatusToApproved() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createPendingProjectMember( + projectTeam, user, TeamRole.BACKEND); + member.softDelete(); // 삭제 상태로 만듦 + + member.toActive(); + + assertAll( + () -> assertEquals(StatusCategory.APPROVED, member.getStatus()), + () -> assertFalse(member.isDeleted()), + () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("toActive(teamRole, isLeader) 호출 시 모든 필드가 갱신된다") + void toActiveWithParametersUpdatesAllFields() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createPendingProjectMember( + projectTeam, user, TeamRole.BACKEND); + member.softDelete(); + + member.toActive(TeamRole.FULLSTACK, true); + + assertAll( + () -> assertEquals(TeamRole.FULLSTACK, member.getTeamRole()), + () -> assertTrue(member.isLeader()), + () -> assertEquals(StatusCategory.APPROVED, member.getStatus()), + () -> assertFalse(member.isDeleted()), + () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("toApplicant 호출 시 상태가 PENDING으로 변경된다") + void toApplicantChangesStatusToPending() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + member.softDelete(); + + member.toApplicant(); + + assertAll( + () -> assertEquals(StatusCategory.PENDING, member.getStatus()), + () -> assertFalse(member.isDeleted()), + () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("toReject 호출 시 상태가 REJECT로 변경된다") + void toRejectChangesStatusToReject() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember member = + ProjectMemberTestHelper.createPendingProjectMember( + projectTeam, user, TeamRole.BACKEND); + member.softDelete(); + + member.toReject(); + + assertAll( + () -> assertEquals(StatusCategory.REJECT, member.getStatus()), + () -> assertFalse(member.isDeleted()), + () -> assertNotNull(member.getUpdatedAt())); + } + + @Test + @DisplayName("isPending은 PENDING 상태일 때 true를 반환한다") + void isPendingReturnsTrueForPendingStatus() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember pending = + ProjectMemberTestHelper.createPendingProjectMember( + projectTeam, user, TeamRole.BACKEND); + ProjectMember approved = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + + assertAll(() -> assertTrue(pending.isPending()), () -> assertFalse(approved.isPending())); + } + + @Test + @DisplayName("isApproved는 APPROVED 상태이고 삭제되지 않았을 때 true를 반환한다") + void isApprovedReturnsTrueForApprovedAndNotDeleted() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember approved = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + ProjectMember deleted = + ProjectMemberTestHelper.createDeletedProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + ProjectMember pending = + ProjectMemberTestHelper.createPendingProjectMember( + projectTeam, user, TeamRole.BACKEND); + + assertAll( + () -> assertTrue(approved.isApproved()), + () -> assertFalse(deleted.isApproved()), + () -> assertFalse(pending.isApproved())); + } + + @Test + @DisplayName("isRejected는 REJECT 상태일 때 true를 반환한다") + void isRejectedReturnsTrueForRejectStatus() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember rejected = + ProjectMemberTestHelper.createRejectedProjectMember( + projectTeam, user, TeamRole.BACKEND); + ProjectMember approved = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + + assertAll( + () -> assertTrue(rejected.isRejected()), () -> assertFalse(approved.isRejected())); + } + + @Test + @DisplayName("isActive는 APPROVED 상태이고 삭제되지 않았을 때 true를 반환한다") + void isActiveReturnsTrueForApprovedAndNotDeleted() { + var projectTeam = ProjectMemberTestHelper.createProjectTeam("Test Team"); + var user = ProjectMemberTestHelper.createUser("1"); + + ProjectMember active = + ProjectMemberTestHelper.createProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + ProjectMember deleted = + ProjectMemberTestHelper.createDeletedProjectMember( + projectTeam, user, TeamRole.BACKEND, true); + ProjectMember pending = + ProjectMemberTestHelper.createPendingProjectMember( + projectTeam, user, TeamRole.BACKEND); + + assertAll( + () -> assertTrue(active.isActive()), + () -> assertFalse(deleted.isActive()), + () -> assertFalse(pending.isActive())); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/helper/ProjectMemberTestHelper.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/helper/ProjectMemberTestHelper.java new file mode 100644 index 00000000..0ea38607 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/helper/ProjectMemberTestHelper.java @@ -0,0 +1,143 @@ +package backend.techeerzip.domain.projectMember.helper; + +import org.springframework.test.util.ReflectionTestUtils; + +import backend.techeerzip.domain.projectMember.entity.ProjectMember; +import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; +import backend.techeerzip.domain.projectTeam.type.TeamRole; +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.global.entity.StatusCategory; + +/** 테스트용 ProjectMember 객체 생성을 돕는 헬퍼 클래스입니다. */ +public class ProjectMemberTestHelper { + + private ProjectMemberTestHelper() {} + + // ========================================================================= + // 1. ProjectMember 생성 메서드 + // ========================================================================= + + /** 가장 일반적인 '승인된(APPROVED)' 프로젝트 멤버를 생성합니다. */ + public static ProjectMember createProjectMember( + ProjectTeam projectTeam, User user, TeamRole teamRole, boolean isLeader) { + return createProjectMember(projectTeam, user, teamRole, isLeader, StatusCategory.APPROVED); + } + + /** 상태를 지정하여 프로젝트 멤버를 생성합니다. (메인 빌더) */ + public static ProjectMember createProjectMember( + ProjectTeam projectTeam, + User user, + TeamRole teamRole, + boolean isLeader, + StatusCategory status) { + return ProjectMember.builder() + .projectTeam(projectTeam) + .user(user) + .teamRole(teamRole) + .isLeader(isLeader) + .status(status) + .summary("테스트용 프로젝트 멤버 요약") + .build(); + } + + /** '대기 중(PENDING)' 상태의 프로젝트 멤버를 생성합니다. */ + public static ProjectMember createPendingProjectMember( + ProjectTeam projectTeam, User user, TeamRole teamRole) { + return createProjectMember(projectTeam, user, teamRole, false, StatusCategory.PENDING); + } + + /** '거부된(REJECT)' 상태의 프로젝트 멤버를 생성합니다. */ + public static ProjectMember createRejectedProjectMember( + ProjectTeam projectTeam, User user, TeamRole teamRole) { + return createProjectMember(projectTeam, user, teamRole, false, StatusCategory.REJECT); + } + + /** '삭제된' 상태의 프로젝트 멤버를 생성합니다. */ + public static ProjectMember createDeletedProjectMember( + ProjectTeam projectTeam, User user, TeamRole teamRole, boolean isLeader) { + ProjectMember member = + createProjectMember(projectTeam, user, teamRole, isLeader, StatusCategory.APPROVED); + member.softDelete(); + return member; + } + + /** ID를 지정하여 프로젝트 멤버를 생성합니다. */ + public static ProjectMember createProjectMemberWithId( + Long id, + ProjectTeam projectTeam, + User user, + TeamRole teamRole, + boolean isLeader, + StatusCategory status) { + ProjectMember member = createProjectMember(projectTeam, user, teamRole, isLeader, status); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + // ========================================================================= + // 2. ProjectTeam 생성 메서드 + // ========================================================================= + + /** 테스트용 ProjectTeam 엔티티를 생성합니다. */ + public static ProjectTeam createProjectTeam(String name) { + return ProjectTeam.builder() + .name(name) + .githubLink("https://github.com/test/" + name) + .notionLink("https://notion.so/test/" + name) + .projectExplain("테스트용 프로젝트 설명: " + name) + .recruitExplain("테스트용 모집 설명: " + name) + .isRecruited(true) + .isFinished(false) + .frontendNum(1) + .backendNum(1) + .fullStackNum(1) + .devopsNum(1) + .dataEngineerNum(1) + .build(); + } + + /** ID를 지정하여 ProjectTeam 엔티티를 생성합니다. */ + public static ProjectTeam createProjectTeamWithId(Long id, String name) { + ProjectTeam team = createProjectTeam(name); + ReflectionTestUtils.setField(team, "id", id); + return team; + } + + // ========================================================================= + // 3. User 생성 메서드 + // ========================================================================= + + /** 테스트용 User 엔티티를 생성합니다. */ + public static User createUser(String suffix) { + Role role = new Role("ROLE_USER"); + return User.builder() + .name("테스터" + suffix) + .email("tester" + suffix + "@example.com") + .nickname("tester" + suffix) + .year(2025) + .password("password") + .isLft(false) + .githubUrl("https://github.com/tester" + suffix) + .mainPosition("BE") + .subPosition("FE") + .school("Techeer") + .profileImage(null) + .isAuth(true) + .role(role) + .grade("A") + .mediumUrl(null) + .tistoryUrl(null) + .velogUrl(null) + .bootcampYear(2025) + .feedbackNotes("notes") + .build(); + } + + /** ID를 지정하여 User 엔티티를 생성합니다. */ + public static User createUserWithId(Long id, String suffix) { + User user = createUser(suffix); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/repository/ProjectMemberRepositoryTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/repository/ProjectMemberRepositoryTest.java index 1d89aae6..27355ec5 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/repository/ProjectMemberRepositoryTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/repository/ProjectMemberRepositoryTest.java @@ -1,148 +1,556 @@ -// package backend.techeerzip.domain.projectMember.repository; -// -// import java.util.List; -// -// import org.junit.jupiter.api.Assertions; -// import org.junit.jupiter.api.BeforeEach; -// import org.junit.jupiter.api.Test; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.boot.autoconfigure.domain.EntityScan; -// import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -// import org.springframework.test.context.ActiveProfiles; -// -// import backend.techeerzip.domain.projectMember.entity.ProjectMember; -// import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; -// import backend.techeerzip.domain.projectTeam.repository.ProjectTeamRepository; -// import backend.techeerzip.domain.projectTeam.type.TeamRole; -// import backend.techeerzip.domain.role.entity.Role; -// import backend.techeerzip.domain.role.repository.RoleRepository; -// import backend.techeerzip.domain.user.entity.User; -// import backend.techeerzip.domain.user.repository.UserRepository; -// import backend.techeerzip.global.entity.StatusCategory; -// -// @ActiveProfiles("test") -// @DataJpaTest -// @EntityScan(basePackages = "backend.techeerzip.domain") -// class ProjectMemberRepositoryTest { -// -// @Autowired private ProjectMemberRepository projectMemberRepository; -// @Autowired private ProjectTeamRepository projectTeamRepository; -// @Autowired private UserRepository userRepository; -// -// private ProjectTeam savedTeam; -// private User user; -// @Autowired private RoleRepository roleRepository; -// -// @BeforeEach -// void setup() { -// final ProjectTeam mockPT = -// ProjectTeam.builder() -// .projectExplain("") -// .name("name") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build(); -// savedTeam = projectTeamRepository.save(mockPT); -// final User mockU = -// User.builder() -// .name("") -// .email("") -// .grade("") -// .isLft(true) -// .isAuth(true) -// .mainPosition("") -// .password("") -// .githubUrl("") -// .mediumUrl("") -// .profileImage("") -// .velogUrl("") -// .year(1) -// .role(roleRepository.save(new Role(""))) -// .school("") -// .build(); -// user = userRepository.save(mockU); -// } -// -// @Test -// void findByProjectTeamId() { -// final ProjectMember pm = -// ProjectMember.builder() -// .projectTeam(savedTeam) -// .teamRole(TeamRole.BACKEND) -// .status(StatusCategory.APPROVED) -// .summary("") -// .isLeader(true) -// .user(user) -// .build(); -// -// final ProjectMember saved = projectMemberRepository.save(pm); -// final ProjectMember find = -// projectMemberRepository.findByProjectTeamId(savedTeam.getId()).orElseThrow(); -// Assertions.assertEquals(saved, find); -// } -// -// @Test -// void findAllByProjectTeamId() { -// final ProjectMember pm = -// ProjectMember.builder() -// .projectTeam(savedTeam) -// .teamRole(TeamRole.BACKEND) -// .status(StatusCategory.APPROVED) -// .summary("") -// .isLeader(true) -// .user(user) -// .build(); -// final ProjectMember saved = projectMemberRepository.save(pm); -// final List find = -// projectMemberRepository.findAllByProjectTeamId(savedTeam.getId()); -// Assertions.assertEquals(saved, find.getFirst()); -// } -// -// @Test -// void jpaIsDeletedTrueThenFalseTest() { -// final ProjectMember pm = -// ProjectMember.builder() -// .projectTeam(savedTeam) -// .teamRole(TeamRole.BACKEND) -// .status(StatusCategory.APPROVED) -// .summary("") -// .isLeader(true) -// .user(user) -// .build(); -// pm.softDelete(); -// projectMemberRepository.save(pm); -// final ProjectMember deleted = -// projectMemberRepository.findByProjectTeamId(savedTeam.getId()).orElseThrow(); -// final boolean isMember = -// projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// user.getId(), savedTeam.getId(), StatusCategory.APPROVED); -// Assertions.assertFalse(isMember); -// Assertions.assertTrue(deleted.isDeleted()); -// } -// -// @Test -// void existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus() { -// final ProjectMember pm = -// ProjectMember.builder() -// .projectTeam(savedTeam) -// .teamRole(TeamRole.BACKEND) -// .status(StatusCategory.APPROVED) -// .summary("") -// .isLeader(true) -// .user(user) -// .build(); -// projectMemberRepository.save(pm); -// final boolean isMember = -// projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// user.getId(), savedTeam.getId(), StatusCategory.APPROVED); -// Assertions.assertTrue(isMember); -// } -// } +package backend.techeerzip.domain.projectMember.repository; + +import static backend.techeerzip.domain.projectMember.helper.ProjectMemberTestHelper.*; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import backend.techeerzip.config.RepositoryTestSupport; +import backend.techeerzip.domain.projectMember.entity.ProjectMember; +import backend.techeerzip.domain.projectTeam.dto.response.LeaderInfo; +import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; +import backend.techeerzip.domain.projectTeam.repository.ProjectTeamRepository; +import backend.techeerzip.domain.projectTeam.type.TeamRole; +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.role.repository.RoleRepository; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.repository.UserRepository; +import backend.techeerzip.global.config.QueryDslConfig; +import backend.techeerzip.global.entity.StatusCategory; + +@DataJpaTest +@Import({QueryDslConfig.class, ProjectMemberDslRepositoryImpl.class}) +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ProjectMemberRepositoryTest extends RepositoryTestSupport { + + @Autowired private ProjectMemberRepository projectMemberRepository; + @Autowired private ProjectMemberDslRepository projectMemberDslRepository; + @Autowired private ProjectTeamRepository projectTeamRepository; + @Autowired private UserRepository userRepository; + @Autowired private RoleRepository roleRepository; + @Autowired private EntityManager entityManager; + + private ProjectTeam savedTeam; + private User savedUser; + private Role savedRole; + + @BeforeEach + void setUp() { + projectMemberRepository.deleteAll(); + projectTeamRepository.deleteAll(); + userRepository.deleteAll(); + roleRepository.deleteAll(); + entityManager.flush(); + entityManager.clear(); + + // 기본 Role 생성 + savedRole = roleRepository.save(new Role("ROLE_USER")); + + // 기본 ProjectTeam 생성 + savedTeam = projectTeamRepository.save(createProjectTeam("Test Team")); + + // 기본 User 생성 + User user = createUser("1"); + user.setRole(savedRole); + savedUser = userRepository.save(user); + + clearPersistenceContext(); + } + + @Nested + @DisplayName("프로젝트 멤버 조회 (findByProjectTeamId)") + class FindByProjectTeamIdTest { + + @Test + @DisplayName("성공: 프로젝트 팀 ID로 멤버를 조회한다") + @Transactional + void findByProjectTeamIdSuccess() { + // Given + ProjectMember member = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + projectMemberRepository.save(member); + clearPersistenceContext(); + + // When + Optional result = + projectMemberRepository.findByProjectTeamId(savedTeam.getId()); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getProjectTeam().getId()).isEqualTo(savedTeam.getId()); + assertThat(result.get().getUser().getId()).isEqualTo(savedUser.getId()); + } + + @Test + @DisplayName("성공: 삭제된 멤버는 조회되지 않는다") + @Transactional + void findByProjectTeamIdExcludesDeleted() { + // Given - findByProjectTeamId는 단일 결과를 반환하므로, active 멤버 1명만 생성 + // 삭제된 멤버는 다른 팀에 생성하여 unique constraint 회피 + ProjectTeam team2 = createProjectTeam("팀2"); + ProjectTeam savedTeam2 = projectTeamRepository.save(team2); + + ProjectMember active = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember deleted = + createDeletedProjectMember(savedTeam2, savedUser, TeamRole.FRONTEND, false); + + projectMemberRepository.saveAll(List.of(active, deleted)); + clearPersistenceContext(); + + // When + List result = + projectMemberRepository.findAllByProjectTeamId(savedTeam.getId()); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().isDeleted()).isFalse(); + assertThat(result.getFirst().getUser().getId()).isEqualTo(savedUser.getId()); + } + + @Test + @DisplayName("성공: 해당 팀의 멤버가 없으면 Optional.empty()를 반환한다") + @Transactional + void findByProjectTeamIdReturnsEmpty() { + // When + Optional result = + projectMemberRepository.findByProjectTeamId(savedTeam.getId()); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("프로젝트 멤버 목록 조회 (findAllByProjectTeamId)") + class FindAllByProjectTeamIdTest { + + @Test + @DisplayName("성공: 프로젝트 팀의 모든 멤버를 조회한다") + @Transactional + void findAllByProjectTeamIdSuccess() { + // Given + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + ProjectMember member1 = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember member2 = + createProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND, false); + + projectMemberRepository.saveAll(List.of(member1, member2)); + clearPersistenceContext(); + + // When + List result = + projectMemberRepository.findAllByProjectTeamId(savedTeam.getId()); + + // Then + assertThat(result).hasSize(2); + assertThat(result) + .extracting(ProjectMember::getUser) + .extracting(User::getId) + .containsExactly(savedUser.getId(), savedUser2.getId()); + } + + @Test + @DisplayName("성공: 삭제된 멤버도 포함하여 조회한다") + @Transactional + void findAllByProjectTeamIdIncludesDeleted() { + // Given - 다른 유저를 사용하여 unique constraint 회피 + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + ProjectMember active = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember deleted = + createDeletedProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND, false); + + projectMemberRepository.saveAll(List.of(active, deleted)); + clearPersistenceContext(); + + // When + List result = + projectMemberRepository.findAllByProjectTeamId(savedTeam.getId()); + + // Then + assertThat(result).hasSize(2); + assertThat(result).extracting(ProjectMember::isDeleted).contains(true, false); + } + } + + @Nested + @DisplayName("멤버 존재 여부 확인 (existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus)") + class ExistsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatusTest { + + @Test + @DisplayName("성공: 조건에 맞는 멤버가 존재하면 true를 반환한다") + @Transactional + void existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatusReturnsTrue() { + // Given + ProjectMember member = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + projectMemberRepository.save(member); + clearPersistenceContext(); + + // When + boolean exists = + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + savedUser.getId(), savedTeam.getId(), StatusCategory.APPROVED); + + // Then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("성공: 삭제된 멤버는 false를 반환한다") + @Transactional + void existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatusExcludesDeleted() { + // Given + ProjectMember deleted = + createDeletedProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + projectMemberRepository.save(deleted); + clearPersistenceContext(); + + // When + boolean exists = + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + savedUser.getId(), savedTeam.getId(), StatusCategory.APPROVED); + + // Then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("성공: 상태가 다른 멤버는 false를 반환한다") + @Transactional + void existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatusChecksStatus() { + // Given + ProjectMember pending = + createPendingProjectMember(savedTeam, savedUser, TeamRole.BACKEND); + projectMemberRepository.save(pending); + clearPersistenceContext(); + + // When + boolean exists = + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + savedUser.getId(), savedTeam.getId(), StatusCategory.APPROVED); + + // Then + assertThat(exists).isFalse(); + } + } + + @Nested + @DisplayName("프로젝트 멤버 조회 (findByProjectTeamIdAndUserId)") + class FindByProjectTeamIdAndUserIdTest { + + @Test + @DisplayName("성공: 팀 ID와 유저 ID로 멤버를 조회한다") + @Transactional + void findByProjectTeamIdAndUserIdSuccess() { + // Given + ProjectMember member = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + projectMemberRepository.save(member); + clearPersistenceContext(); + + // When + Optional result = + projectMemberRepository.findByProjectTeamIdAndUserId( + savedTeam.getId(), savedUser.getId()); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getProjectTeam().getId()).isEqualTo(savedTeam.getId()); + assertThat(result.get().getUser().getId()).isEqualTo(savedUser.getId()); + } + + @Test + @DisplayName("성공: 삭제된 멤버도 조회된다") + @Transactional + void findByProjectTeamIdAndUserIdIncludesDeleted() { + // Given + ProjectMember deleted = + createDeletedProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + projectMemberRepository.save(deleted); + clearPersistenceContext(); + + // When + Optional result = + projectMemberRepository.findByProjectTeamIdAndUserId( + savedTeam.getId(), savedUser.getId()); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().isDeleted()).isTrue(); + } + } + + @Nested + @DisplayName("상태별 프로젝트 멤버 조회 (findByProjectTeamIdAndUserIdAndStatus)") + class FindByProjectTeamIdAndUserIdAndStatusTest { + + @Test + @DisplayName("성공: 팀 ID, 유저 ID, 상태로 멤버를 조회한다") + @Transactional + void findByProjectTeamIdAndUserIdAndStatusSuccess() { + // Given + ProjectMember member = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + projectMemberRepository.save(member); + clearPersistenceContext(); + + // When + Optional result = + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + savedTeam.getId(), savedUser.getId(), StatusCategory.APPROVED); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getStatus()).isEqualTo(StatusCategory.APPROVED); + } + + @Test + @DisplayName("성공: 상태가 다른 멤버는 조회되지 않는다") + @Transactional + void findByProjectTeamIdAndUserIdAndStatusChecksStatus() { + // Given + ProjectMember pending = + createPendingProjectMember(savedTeam, savedUser, TeamRole.BACKEND); + projectMemberRepository.save(pending); + clearPersistenceContext(); + + // When + Optional result = + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + savedTeam.getId(), savedUser.getId(), StatusCategory.APPROVED); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("유저별 멤버 삭제 (updateIsDeletedByUserId)") + class UpdateIsDeletedByUserIdTest { + + @Test + @DisplayName("성공: 특정 유저의 모든 프로젝트 멤버를 삭제한다") + @Transactional + void updateIsDeletedByUserIdSuccess() { + // Given + ProjectTeam team2 = projectTeamRepository.save(createProjectTeam("Test Team 2")); + + ProjectMember member1 = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember member2 = createProjectMember(team2, savedUser, TeamRole.FRONTEND, false); + + projectMemberRepository.saveAll(List.of(member1, member2)); + clearPersistenceContext(); + + // When + projectMemberRepository.updateIsDeletedByUserId(savedUser.getId()); + clearPersistenceContext(); + + // Then + ProjectMember updated1 = + projectMemberRepository.findById(member1.getId()).orElseThrow(); + ProjectMember updated2 = + projectMemberRepository.findById(member2.getId()).orElseThrow(); + + assertThat(updated1.isDeleted()).isTrue(); + assertThat(updated2.isDeleted()).isTrue(); + } + + @Test + @DisplayName("성공: 다른 유저의 멤버는 삭제되지 않는다") + @Transactional + void updateIsDeletedByUserIdOnlyAffectsTargetUser() { + // Given + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + ProjectMember member1 = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember member2 = + createProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND, false); + + projectMemberRepository.saveAll(List.of(member1, member2)); + clearPersistenceContext(); + + // When + projectMemberRepository.updateIsDeletedByUserId(savedUser.getId()); + clearPersistenceContext(); + + // Then + ProjectMember updated1 = + projectMemberRepository.findById(member1.getId()).orElseThrow(); + ProjectMember updated2 = + projectMemberRepository.findById(member2.getId()).orElseThrow(); + + assertThat(updated1.isDeleted()).isTrue(); + assertThat(updated2.isDeleted()).isFalse(); + } + } + + @Nested + @DisplayName("지원자 조회 (findManyApplicants)") + class FindManyApplicantsTest { + + @Test + @DisplayName("성공: PENDING 상태의 멤버만 조회한다") + @Transactional + void findManyApplicantsReturnsOnlyPending() { + // Given - 다른 유저를 사용하여 unique constraint 회피 + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + User user3 = createUser("3"); + user3.setRole(savedRole); + User savedUser3 = userRepository.save(user3); + + ProjectMember approved = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember pending = + createPendingProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND); + ProjectMember rejected = + createRejectedProjectMember(savedTeam, savedUser3, TeamRole.FULLSTACK); + + projectMemberRepository.saveAll(List.of(approved, pending, rejected)); + clearPersistenceContext(); + + // When + var result = projectMemberDslRepository.findManyApplicants(savedTeam.getId()); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getStatus()).isEqualTo(StatusCategory.PENDING); + } + + @Test + @DisplayName("성공: 삭제된 멤버는 조회되지 않는다") + @Transactional + void findManyApplicantsExcludesDeleted() { + // Given - 다른 유저를 사용하여 unique constraint 회피 + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + ProjectMember pending = + createPendingProjectMember(savedTeam, savedUser, TeamRole.BACKEND); + ProjectMember deletedPending = + createPendingProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND); + deletedPending.softDelete(); // 삭제 처리 + + projectMemberRepository.saveAll(List.of(pending, deletedPending)); + clearPersistenceContext(); + + // When + var result = projectMemberDslRepository.findManyApplicants(savedTeam.getId()); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getId()).isEqualTo(pending.getId()); + assertThat(result.getFirst().getStatus()).isEqualTo(StatusCategory.PENDING); + } + } + + @Nested + @DisplayName("리더 조회 (findManyLeaders)") + class FindManyLeadersTest { + + @Test + @DisplayName("성공: 승인된 리더만 조회한다") + @Transactional + void findManyLeadersReturnsOnlyApprovedLeaders() { + // Given - 다른 유저를 사용하여 unique constraint 회피 + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + User user3 = createUser("3"); + user3.setRole(savedRole); + User savedUser3 = userRepository.save(user3); + + User user4 = createUser("4"); + user4.setRole(savedRole); + User savedUser4 = userRepository.save(user4); + + ProjectMember leader1 = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember leader2 = + createProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND, true); + ProjectMember nonLeader = + createProjectMember(savedTeam, savedUser3, TeamRole.DEVOPS, false); + ProjectMember pendingLeader = + createPendingProjectMember(savedTeam, savedUser4, TeamRole.BACKEND); + + projectMemberRepository.saveAll(List.of(leader1, leader2, nonLeader, pendingLeader)); + clearPersistenceContext(); + + // When + var result = projectMemberDslRepository.findManyLeaders(savedTeam.getId()); + + // Then + assertThat(result).hasSize(2); + assertThat(result) + .extracting(LeaderInfo::name) + .containsExactlyInAnyOrder("테스터1", "테스터2"); + } + + @Test + @DisplayName("성공: 삭제된 리더는 조회되지 않는다") + @Transactional + void findManyLeadersExcludesDeleted() { + // Given - 다른 유저를 사용하여 unique constraint 회피 + User user2 = createUser("2"); + user2.setRole(savedRole); + User savedUser2 = userRepository.save(user2); + + ProjectMember leader = + createProjectMember(savedTeam, savedUser, TeamRole.BACKEND, true); + ProjectMember deletedLeader = + createProjectMember(savedTeam, savedUser2, TeamRole.FRONTEND, true); + deletedLeader.softDelete(); // 삭제 처리 + + projectMemberRepository.saveAll(List.of(leader, deletedLeader)); + clearPersistenceContext(); + + // When + var result = projectMemberDslRepository.findManyLeaders(savedTeam.getId()); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("테스터1"); + } + } + + // 반복되는 flush/clear를 위한 유틸 메서드 + private void clearPersistenceContext() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java new file mode 100644 index 00000000..f8258ad4 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java @@ -0,0 +1,457 @@ +package backend.techeerzip.domain.projectMember.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import backend.techeerzip.domain.projectMember.dto.ProjectMemberApplicantResponse; +import backend.techeerzip.domain.projectMember.entity.ProjectMember; +import backend.techeerzip.domain.projectMember.exception.ProjectMemberNotFoundException; +import backend.techeerzip.domain.projectMember.exception.TeamInvalidActiveRequester; +import backend.techeerzip.domain.projectMember.helper.ProjectMemberTestHelper; +import backend.techeerzip.domain.projectMember.repository.ProjectMemberDslRepository; +import backend.techeerzip.domain.projectMember.repository.ProjectMemberRepository; +import backend.techeerzip.domain.projectTeam.dto.response.LeaderInfo; +import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; +import backend.techeerzip.domain.projectTeam.type.TeamRole; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.repository.UserRepository; +import backend.techeerzip.global.entity.StatusCategory; + +@ExtendWith(MockitoExtension.class) +class ProjectMemberServiceTest { + + @Mock private ProjectMemberRepository projectMemberRepository; + @Mock private ProjectMemberDslRepository projectMemberDslRepository; + @Mock private UserRepository userRepository; + + @InjectMocks private ProjectMemberService projectMemberService; + + private ProjectTeam testTeam; + private User testUser; + private Long teamId; + private Long userId; + + @BeforeEach + void setUp() { + teamId = 1L; + userId = 1L; + testTeam = ProjectMemberTestHelper.createProjectTeamWithId(teamId, "Test Team"); + testUser = ProjectMemberTestHelper.createUserWithId(userId, "1"); + } + + @Nested + @DisplayName("활동 멤버 확인 (checkActive)") + class CheckActiveTest { + + @Test + @DisplayName("성공: 활동 멤버인 경우 예외가 발생하지 않는다") + void checkActiveSuccess() { + // Given + given( + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + userId, teamId, StatusCategory.APPROVED)) + .willReturn(true); + + // When & Then - 예외가 발생하지 않아야 함 + projectMemberService.checkActive(teamId, userId); + } + + @Test + @DisplayName("실패: 활동 멤버가 아닌 경우 TeamInvalidActiveRequester 예외가 발생한다") + void checkActiveThrowsExceptionWhenNotActive() { + // Given + given( + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + userId, teamId, StatusCategory.APPROVED)) + .willReturn(false); + + // When & Then + assertThatThrownBy(() -> projectMemberService.checkActive(teamId, userId)) + .isInstanceOf(TeamInvalidActiveRequester.class); + } + } + + @Nested + @DisplayName("활동 여부 조회 (isActive)") + class IsActiveTest { + + @Test + @DisplayName("성공: 활동 멤버인 경우 true를 반환한다") + void isActiveReturnsTrue() { + // Given + given( + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + userId, teamId, StatusCategory.APPROVED)) + .willReturn(true); + + // When + boolean result = projectMemberService.isActive(teamId, userId); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("성공: 활동 멤버가 아닌 경우 false를 반환한다") + void isActiveReturnsFalse() { + // Given + given( + projectMemberRepository + .existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( + userId, teamId, StatusCategory.APPROVED)) + .willReturn(false); + + // When + boolean result = projectMemberService.isActive(teamId, userId); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("지원자 신청 (applyApplicant)") + class ApplyApplicantTest { + + @Test + @DisplayName("성공: 기존 멤버가 없으면 새로 생성한다") + void applyApplicantCreatesNewMember() { + // Given + String summary = "백엔드 개발 경험이 있습니다"; + given(projectMemberRepository.findByProjectTeamIdAndUserId(teamId, userId)) + .willReturn(Optional.empty()); + given(userRepository.getReferenceById(userId)).willReturn(testUser); + + ProjectMember savedMember = + ProjectMemberTestHelper.createPendingProjectMember( + testTeam, testUser, TeamRole.BACKEND); + given(projectMemberRepository.save(any(ProjectMember.class))).willReturn(savedMember); + + // When + ProjectMember result = + projectMemberService.applyApplicant( + testTeam, userId, TeamRole.BACKEND, summary); + + // Then + assertThat(result).isNotNull(); + verify(projectMemberRepository).save(any(ProjectMember.class)); + } + + @Test + @DisplayName("성공: 기존 멤버가 있으면 상태를 PENDING으로 변경한다") + void applyApplicantUpdatesExistingMember() { + // Given + String summary = "백엔드 개발 경험이 있습니다"; + ProjectMember existingMember = + ProjectMemberTestHelper.createProjectMember( + testTeam, testUser, TeamRole.BACKEND, true); + existingMember.toReject(); // REJECT 상태로 변경 + + given(projectMemberRepository.findByProjectTeamIdAndUserId(teamId, userId)) + .willReturn(Optional.of(existingMember)); + + // When + ProjectMember result = + projectMemberService.applyApplicant( + testTeam, userId, TeamRole.BACKEND, summary); + + // Then + assertThat(result.getStatus()).isEqualTo(StatusCategory.PENDING); + assertThat(result.isPending()).isTrue(); + verify(projectMemberRepository, never()).save(any(ProjectMember.class)); + } + + @Test + @DisplayName("성공: 이미 PENDING 상태인 경우 상태를 변경하지 않는다") + void applyApplicantDoesNotChangePendingStatus() { + // Given + String summary = "백엔드 개발 경험이 있습니다"; + ProjectMember pendingMember = + ProjectMemberTestHelper.createPendingProjectMember( + testTeam, testUser, TeamRole.BACKEND); + + given(projectMemberRepository.findByProjectTeamIdAndUserId(teamId, userId)) + .willReturn(Optional.of(pendingMember)); + + // When + ProjectMember result = + projectMemberService.applyApplicant( + testTeam, userId, TeamRole.BACKEND, summary); + + // Then + assertThat(result.getStatus()).isEqualTo(StatusCategory.PENDING); + assertThat(result.isPending()).isTrue(); + } + } + + @Nested + @DisplayName("지원자 목록 조회 (getApplicants)") + class GetApplicantsTest { + + @Test + @DisplayName("성공: PENDING 상태의 지원자 목록을 조회한다") + void getApplicantsReturnsPendingMembers() { + // Given + ProjectMemberApplicantResponse applicant1 = + new ProjectMemberApplicantResponse( + 1L, + TeamRole.BACKEND, + "요약1", + StatusCategory.PENDING, + 1L, + "사용자1", + null, + 2025); + ProjectMemberApplicantResponse applicant2 = + new ProjectMemberApplicantResponse( + 2L, + TeamRole.FRONTEND, + "요약2", + StatusCategory.PENDING, + 2L, + "사용자2", + null, + 2025); + + given(projectMemberDslRepository.findManyApplicants(teamId)) + .willReturn(List.of(applicant1, applicant2)); + + // When + List result = + projectMemberService.getApplicants(teamId); + + // Then + assertThat(result).hasSize(2); + assertThat(result) + .extracting(ProjectMemberApplicantResponse::getStatus) + .containsOnly(StatusCategory.PENDING); + } + + @Test + @DisplayName("성공: 지원자가 없으면 빈 리스트를 반환한다") + void getApplicantsReturnsEmptyList() { + // Given + given(projectMemberDslRepository.findManyApplicants(teamId)).willReturn(List.of()); + + // When + List result = + projectMemberService.getApplicants(teamId); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("지원자 승인 (acceptApplicant)") + class AcceptApplicantTest { + + @Test + @DisplayName("성공: PENDING 상태의 지원자를 APPROVED로 변경한다") + void acceptApplicantChangesStatusToApproved() { + // Given + ProjectMember pendingMember = + ProjectMemberTestHelper.createPendingProjectMember( + testTeam, testUser, TeamRole.BACKEND); + + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) + .willReturn(Optional.of(pendingMember)); + + // When + ProjectMember result = projectMemberService.acceptApplicant(teamId, userId); + + // Then + assertThat(result.getStatus()).isEqualTo(StatusCategory.APPROVED); + assertThat(result.isActive()).isTrue(); + assertThat(result.isDeleted()).isFalse(); + } + + @Test + @DisplayName("실패: PENDING 상태의 지원자가 없으면 ProjectMemberNotFoundException이 발생한다") + void acceptApplicantThrowsExceptionWhenNotFound() { + // Given + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> projectMemberService.acceptApplicant(teamId, userId)) + .isInstanceOf(ProjectMemberNotFoundException.class); + } + + @Test + @DisplayName("실패: 이미 APPROVED 상태인 경우 PENDING으로 조회되지 않아 예외가 발생한다") + void acceptApplicantThrowsExceptionWhenAlreadyApproved() { + // Given + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> projectMemberService.acceptApplicant(teamId, userId)) + .isInstanceOf(ProjectMemberNotFoundException.class); + + // Verify: 서비스가 실제로 'PENDING' 상태를 조건으로 조회를 시도했는지 검증 (이 부분이 추가되어 중복 해결) + verify(projectMemberRepository) + .findByProjectTeamIdAndUserIdAndStatus(teamId, userId, StatusCategory.PENDING); + } + } + + @Nested + @DisplayName("지원자 거절 (rejectApplicant)") + class RejectApplicantTest { + + @Test + @DisplayName("성공: PENDING 상태의 지원자를 REJECT로 변경하고 이메일을 반환한다") + void rejectApplicantChangesStatusToReject() { + // Given + ProjectMember pendingMember = + ProjectMemberTestHelper.createPendingProjectMember( + testTeam, testUser, TeamRole.BACKEND); + String expectedEmail = testUser.getEmail(); + + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) + .willReturn(Optional.of(pendingMember)); + + // When + String result = projectMemberService.rejectApplicant(teamId, userId); + + // Then + assertThat(result).isEqualTo(expectedEmail); + assertThat(pendingMember.getStatus()).isEqualTo(StatusCategory.REJECT); + assertThat(pendingMember.isRejected()).isTrue(); + } + + @Test + @DisplayName("실패: PENDING 상태의 지원자가 없으면 ProjectMemberNotFoundException이 발생한다") + void rejectApplicantThrowsExceptionWhenNotFound() { + // Given + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> projectMemberService.rejectApplicant(teamId, userId)) + .isInstanceOf(ProjectMemberNotFoundException.class); + } + + @Test + @DisplayName("실패: 이미 REJECT 상태인 경우 PENDING으로 조회되지 않아 예외가 발생한다") + void rejectApplicantThrowsExceptionWhenAlreadyRejected() { + // Given + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> projectMemberService.rejectApplicant(teamId, userId)) + .isInstanceOf(ProjectMemberNotFoundException.class); + + // Verify: PENDING 상태로 조회를 시도했는지 호출 여부를 검증하여 코드 중복 해결 + verify(projectMemberRepository) + .findByProjectTeamIdAndUserIdAndStatus(teamId, userId, StatusCategory.PENDING); + } + } + + @Nested + @DisplayName("리더 목록 조회 (getLeaders)") + class GetLeadersTest { + + @Test + @DisplayName("성공: 승인된 리더 목록을 조회한다") + void getLeadersReturnsApprovedLeaders() { + // Given + LeaderInfo leader1 = new LeaderInfo("리더1", "leader1@example.com"); + LeaderInfo leader2 = new LeaderInfo("리더2", "leader2@example.com"); + + given(projectMemberDslRepository.findManyLeaders(teamId)) + .willReturn(List.of(leader1, leader2)); + + // When + List result = projectMemberService.getLeaders(teamId); + + // Then + assertThat(result).hasSize(2); + assertThat(result).extracting(LeaderInfo::name).containsExactly("리더1", "리더2"); + } + + @Test + @DisplayName("성공: 리더가 없으면 빈 리스트를 반환한다") + void getLeadersReturnsEmptyList() { + // Given + given(projectMemberDslRepository.findManyLeaders(teamId)).willReturn(List.of()); + + // When + List result = projectMemberService.getLeaders(teamId); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("멤버 조회 (getMember)") + class GetMemberTest { + + @Test + @DisplayName("성공: 팀 ID와 유저 ID로 멤버를 조회한다") + void getMemberReturnsMember() { + // Given + ProjectMember member = + ProjectMemberTestHelper.createProjectMember( + testTeam, testUser, TeamRole.BACKEND, true); + + given(projectMemberRepository.findByProjectTeamIdAndUserId(teamId, userId)) + .willReturn(Optional.of(member)); + + // When + Optional result = projectMemberService.getMember(teamId, userId); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getProjectTeam().getId()).isEqualTo(teamId); + assertThat(result.get().getUser().getId()).isEqualTo(userId); + } + + @Test + @DisplayName("성공: 멤버가 없으면 Optional.empty()를 반환한다") + void getMemberReturnsEmpty() { + // Given + given(projectMemberRepository.findByProjectTeamIdAndUserId(teamId, userId)) + .willReturn(Optional.empty()); + + // When + Optional result = projectMemberService.getMember(teamId, userId); + + // Then + assertThat(result).isEmpty(); + } + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java new file mode 100644 index 00000000..144e79ba --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/studyMember/entity/StudyMemberTest.java @@ -0,0 +1,457 @@ +package backend.techeerzip.domain.studyMember.entity; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.studyTeam.entity.StudyTeam; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.global.entity.StatusCategory; + +@DisplayName("StudyMember 엔티티 테스트") +class StudyMemberTest { + + private StudyMember studyMember; + private StudyTeam mockStudyTeam; + private User mockUser; + + @BeforeEach + void setUp() { + // Mock 엔티티 생성 (실제 의존성 없이 테스트) + mockStudyTeam = + StudyTeam.builder() + .name("테스트 스터디") + .studyExplain("테스트 설명") + .isRecruited(true) + .build(); + + mockUser = + User.builder() + .name("홍길동") + .email("test@test.com") + .password("password") + .year(21) + .build(); + + studyMember = + StudyMember.builder() + .isLeader(false) + .summary("스터디 멤버입니다.") + .status(StatusCategory.APPROVED) + .studyTeam(mockStudyTeam) + .user(mockUser) + .build(); + } + + @Nested + @DisplayName("엔티티 생성 테스트") + class CreateTest { + + @Test + @DisplayName("StudyMember 빌더로 정상 생성") + void createStudyMember() { + assertNotNull(studyMember); + assertFalse(studyMember.isLeader()); + assertEquals("스터디 멤버입니다.", studyMember.getSummary()); + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + assertFalse(studyMember.isDeleted()); + } + + @Test + @DisplayName("기본값 검증 - isDeleted는 false") + void defaultIsDeletedIsFalse() { + assertFalse(studyMember.isDeleted()); + } + + @Test + @DisplayName("연관관계 설정 검증") + void relationshipSetup() { + assertEquals(mockStudyTeam, studyMember.getStudyTeam()); + assertEquals(mockUser, studyMember.getUser()); + } + } + + @Nested + @DisplayName("update 메서드 테스트") + class UpdateTest { + + @Test + @DisplayName("요약과 상태를 업데이트하면 필드 값이 변경됨") + void updateSummaryAndStatus() { + String newSummary = "새로운 자기소개입니다."; + StatusCategory newStatus = StatusCategory.PENDING; + + studyMember.update(newSummary, newStatus); + + assertEquals(newSummary, studyMember.getSummary()); + assertEquals(newStatus, studyMember.getStatus()); + } + + @Test + @DisplayName("update 호출 시 updatedAt이 갱신됨") + void updateUpdatesTimestamp() { + LocalDateTime beforeUpdate = studyMember.getUpdatedAt(); + + // 시간 차이를 만들기 위해 잠시 대기 + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + studyMember.update("새 요약", StatusCategory.REJECT); + + assertNotNull(studyMember.getUpdatedAt()); + // updatedAt이 변경되었는지 확인 (null이 아닌 경우에만) + if (beforeUpdate != null) { + assertTrue( + studyMember.getUpdatedAt().isAfter(beforeUpdate) + || studyMember.getUpdatedAt().equals(beforeUpdate)); + } + } + } + + @Nested + @DisplayName("changeLeaderStatus 메서드 테스트") + class ChangeLeaderStatusTest { + + @Test + @DisplayName("리더 상태를 true로 변경") + void changeToLeader() { + assertFalse(studyMember.isLeader()); + + studyMember.changeLeaderStatus(true); + + assertTrue(studyMember.isLeader()); + } + + @Test + @DisplayName("리더 상태를 false로 변경") + void changeToMember() { + studyMember.changeLeaderStatus(true); // 먼저 리더로 변경 + assertTrue(studyMember.isLeader()); + + studyMember.changeLeaderStatus(false); + + assertFalse(studyMember.isLeader()); + } + + @Test + @DisplayName("리더 상태 변경 시 updatedAt 갱신") + void changeLeaderStatusUpdatesTimestamp() { + studyMember.changeLeaderStatus(true); + assertNotNull(studyMember.getUpdatedAt()); + } + } + + @Nested + @DisplayName("softDelete 메서드 테스트") + class SoftDeleteTest { + + @Test + @DisplayName("소프트 삭제 시 isDeleted가 true로 변경") + void softDeleteChangesIsDeletedToTrue() { + assertFalse(studyMember.isDeleted()); + + studyMember.softDelete(); + + assertTrue(studyMember.isDeleted()); + } + + @Test + @DisplayName("이미 삭제된 경우 중복 삭제해도 문제 없음") + void softDeleteAlreadyDeletedMember() { + studyMember.softDelete(); + assertTrue(studyMember.isDeleted()); + + // 중복 삭제 시도 + studyMember.softDelete(); + + assertTrue(studyMember.isDeleted()); // 여전히 삭제 상태 + } + + @Test + @DisplayName("소프트 삭제 시 updatedAt 갱신") + void softDeleteUpdatesTimestamp() { + studyMember.softDelete(); + assertNotNull(studyMember.getUpdatedAt()); + } + } + + @Nested + @DisplayName("toActive 메서드 테스트") + class ToActiveTest { + + @Test + @DisplayName("toActive(Boolean) 호출 시 리더 상태와 함께 활성화") + void toActiveWithLeaderStatus() { + studyMember = + StudyMember.builder() + .isLeader(false) + .summary("테스트") + .status(StatusCategory.PENDING) + .studyTeam(mockStudyTeam) + .user(mockUser) + .build(); + studyMember.softDelete(); // 삭제 상태로 만듦 + + studyMember.toActive(true); // 리더로 활성화 + + assertTrue(studyMember.isLeader()); + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + assertFalse(studyMember.isDeleted()); + } + + @Test + @DisplayName("toActive() 호출 시 리더 상태 유지하고 활성화") + void toActiveWithoutLeaderChange() { + studyMember = + StudyMember.builder() + .isLeader(true) + .summary("테스트") + .status(StatusCategory.PENDING) + .studyTeam(mockStudyTeam) + .user(mockUser) + .build(); + studyMember.softDelete(); // 삭제 상태로 만듦 + + studyMember.toActive(); // 리더 상태 유지하면서 활성화 + + assertTrue(studyMember.isLeader()); // 리더 상태 유지 + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + assertFalse(studyMember.isDeleted()); + } + + @Test + @DisplayName("toActive 호출 시 updatedAt 갱신") + void toActiveUpdatesTimestamp() { + studyMember.toActive(false); + assertNotNull(studyMember.getUpdatedAt()); + } + } + + @Nested + @DisplayName("toApplicant 메서드 테스트") + class ToApplicantTest { + + @Test + @DisplayName("toApplicant 호출 시 상태가 PENDING으로 변경") + void toApplicantChangeStatusToPending() { + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + + studyMember.toApplicant(); + + assertEquals(StatusCategory.PENDING, studyMember.getStatus()); + } + + @Test + @DisplayName("toApplicant 호출 시 updatedAt 갱신") + void toApplicantUpdatesTimestamp() { + studyMember.toApplicant(); + assertNotNull(studyMember.getUpdatedAt()); + } + } + + @Nested + @DisplayName("toReject 메서드 테스트") + class ToRejectTest { + + @Test + @DisplayName("toReject 호출 시 상태가 REJECT로 변경") + void toRejectChangeStatusToReject() { + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + + studyMember.toReject(); + + assertEquals(StatusCategory.REJECT, studyMember.getStatus()); + } + + @Test + @DisplayName("toReject 호출 시 updatedAt 갱신") + void toRejectUpdatesTimestamp() { + studyMember.toReject(); + assertNotNull(studyMember.getUpdatedAt()); + } + } + + @Nested + @DisplayName("상태 확인 메서드 테스트") + class StatusCheckTest { + + @Test + @DisplayName("isActive - APPROVED이고 삭제되지 않은 경우 true") + void isActiveWhenApprovedAndNotDeleted() { + studyMember = + StudyMember.builder() + .isLeader(false) + .summary("테스트") + .status(StatusCategory.APPROVED) + .studyTeam(mockStudyTeam) + .user(mockUser) + .build(); + + assertTrue(studyMember.isActive()); + } + + @Test + @DisplayName("isActive - 삭제된 경우 false") + void isActiveWhenDeleted() { + studyMember.softDelete(); + + assertFalse(studyMember.isActive()); + } + + @Test + @DisplayName("isActive - PENDING 상태인 경우 false") + void isActiveWhenPending() { + studyMember.toApplicant(); + + assertFalse(studyMember.isActive()); + } + + @Test + @DisplayName("isPending - PENDING 상태인 경우 true") + void isPendingWhenStatusIsPending() { + studyMember.toApplicant(); + + assertTrue(studyMember.isPending()); + } + + @Test + @DisplayName("isPending - APPROVED 상태인 경우 false") + void isPendingWhenStatusIsApproved() { + assertFalse(studyMember.isPending()); + } + + @Test + @DisplayName("isRejected - REJECT 상태인 경우 true") + void isRejectedWhenStatusIsReject() { + studyMember.toReject(); + + assertTrue(studyMember.isRejected()); + } + + @Test + @DisplayName("isRejected - APPROVED 상태인 경우 false") + void isRejectedWhenStatusIsApproved() { + assertFalse(studyMember.isRejected()); + } + } + + @Nested + @DisplayName("상태 전이 시나리오 테스트") + class StateTransitionTest { + + @Test + @DisplayName("지원 → 승인 → 재지원 → 거절 플로우") + void fullLifecycleTest() { + // 1. 최초 지원 (PENDING) + StudyMember applicant = + StudyMember.builder() + .isLeader(false) + .summary("지원합니다") + .status(StatusCategory.PENDING) + .studyTeam(mockStudyTeam) + .user(mockUser) + .build(); + assertTrue(applicant.isPending()); + assertFalse(applicant.isActive()); + + // 2. 승인 (APPROVED) + applicant.toActive(); + assertFalse(applicant.isPending()); + assertTrue(applicant.isActive()); + assertEquals(StatusCategory.APPROVED, applicant.getStatus()); + + // 3. 다른 팀에 재지원 (PENDING으로 변경) + applicant.toApplicant(); + assertTrue(applicant.isPending()); + assertFalse(applicant.isActive()); + + // 4. 거절 (REJECT) + applicant.toReject(); + assertTrue(applicant.isRejected()); + assertFalse(applicant.isActive()); + assertFalse(applicant.isPending()); + } + + @Test + @DisplayName("리더 승급 시나리오") + void leaderPromotionScenario() { + // 일반 멤버로 시작 + assertFalse(studyMember.isLeader()); + assertTrue(studyMember.isActive()); + + // 리더로 승급 + studyMember.changeLeaderStatus(true); + assertTrue(studyMember.isLeader()); + assertTrue(studyMember.isActive()); // 여전히 활성 상태 + + // 다시 일반 멤버로 + studyMember.changeLeaderStatus(false); + assertFalse(studyMember.isLeader()); + assertTrue(studyMember.isActive()); // 여전히 활성 상태 + } + + @Test + @DisplayName("탈퇴 후 재가입 시나리오") + void withdrawAndRejoinScenario() { + // 활성 멤버 + assertTrue(studyMember.isActive()); + + // 탈퇴 (소프트 삭제) + studyMember.softDelete(); + assertFalse(studyMember.isActive()); + assertTrue(studyMember.isDeleted()); + + // 재가입 (다시 활성화) + studyMember.toActive(false); + assertTrue(studyMember.isActive()); + assertFalse(studyMember.isDeleted()); + assertEquals(StatusCategory.APPROVED, studyMember.getStatus()); + } + } + + @Nested + @DisplayName("엣지 케이스 테스트") + class EdgeCaseTest { + + @Test + @DisplayName("summary가 최대 길이(3000자)인 경우") + void maximumSummaryLength() { + String longSummary = "a".repeat(3000); + studyMember.update(longSummary, StatusCategory.APPROVED); + + assertEquals(3000, studyMember.getSummary().length()); + } + + @Test + @DisplayName("동일한 상태로 여러 번 변경해도 문제 없음") + void sameStatusMultipleTimes() { + studyMember.toApplicant(); + assertTrue(studyMember.isPending()); + + studyMember.toApplicant(); // 중복 호출 + assertTrue(studyMember.isPending()); + + studyMember.toApplicant(); // 또 중복 호출 + assertTrue(studyMember.isPending()); + } + + @Test + @DisplayName("삭제된 상태에서도 상태 변경 가능") + void statusChangeWhileDeleted() { + studyMember.softDelete(); + assertTrue(studyMember.isDeleted()); + + studyMember.toApplicant(); + assertEquals(StatusCategory.PENDING, studyMember.getStatus()); + assertTrue(studyMember.isDeleted()); // 여전히 삭제 상태 + } + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsSchedulerUnitTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsSchedulerUnitTest.java new file mode 100644 index 00000000..91e13f86 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsSchedulerUnitTest.java @@ -0,0 +1,89 @@ +package backend.techeerzip.domain.todayCs.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.context.ApplicationEventPublisher; + +import backend.techeerzip.domain.todayCs.exception.CsProblemNotFoundException; +import backend.techeerzip.domain.todayCs.service.TodayCsService; +import backend.techeerzip.infra.slack.event.SlackEvent; +import backend.techeerzip.infra.slack.event.SlackEvent.Channel; +import backend.techeerzip.infra.slack.util.SlackChannelType; + +@ExtendWith(MockitoExtension.class) +class TodayCsSchedulerUnitTest { + + @Mock private TodayCsService todayCsService; + + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks private TodayCsScheduler todayCsScheduler; + + @Test + @DisplayName("성공: CS 문제 발행이 정상적으로 완료된다") + void publishCsProblemSuccess() { + // Given + when(todayCsService.publishCsProblem(any())).thenReturn(null); + + // When + todayCsScheduler.todayCsPublishScheduler(); + + // Then + verify(todayCsService, times(1)).publishCsProblem(any()); + } + + @Test + @DisplayName("실패: 출제할 문제가 없을 때 경고 메시지를 발송한다") + void publishCsProblemFailureWhenNoProblemAvailable() { + // Given + doThrow(new CsProblemNotFoundException()).when(todayCsService).publishCsProblem(any()); + + // When + todayCsScheduler.todayCsPublishScheduler(); + + // Then + verify(todayCsService, times(1)).publishCsProblem(any()); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(SlackEvent.Channel.class); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + + SlackEvent.Channel capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.getMessage()).isEqualTo("[WARNING] 출제 할 문제가 없습니다!"); + assertThat(capturedEvent.getChannelType()).isEqualTo(SlackChannelType.EA); + } + + @Test + @DisplayName("실패: 예상치 못한 예외 발생 시 에러 메시지를 발송한다") + void publishCsProblemFailureWhenUnexpectedException() { + // Given + String errorMessage = "데이터베이스 연결 오류"; + doThrow(new RuntimeException(errorMessage)).when(todayCsService).publishCsProblem(any()); + + // When + todayCsScheduler.todayCsPublishScheduler(); + + // Then + verify(todayCsService, times(1)).publishCsProblem(any()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SlackEvent.Channel.class); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + + SlackEvent.Channel capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.getMessage()).contains("[ERROR]", "CS 문제 발행 중 오류 발생"); + assertThat(capturedEvent.getMessage()).contains(errorMessage); + assertThat(capturedEvent.getChannelType()).isEqualTo(SlackChannelType.EA); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/service/TodayCsServiceIntegrationTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/service/TodayCsServiceIntegrationTest.java index d4865376..8506544e 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/service/TodayCsServiceIntegrationTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/todayCs/service/TodayCsServiceIntegrationTest.java @@ -1,6 +1,9 @@ // package backend.techeerzip.domain.todayCs.service; // // import static org.mockito.ArgumentMatchers.any; +// import static org.mockito.Mockito.doThrow; +// import static org.mockito.Mockito.times; +// import static org.mockito.Mockito.verify; // import static org.mockito.Mockito.when; // // import java.util.Optional; @@ -10,6 +13,7 @@ // import org.springframework.beans.factory.annotation.Autowired; // import org.springframework.boot.test.context.SpringBootTest; // import org.springframework.boot.test.mock.mockito.MockBean; +// import org.springframework.context.ApplicationEventPublisher; // import org.springframework.context.annotation.Import; // import org.springframework.test.context.ActiveProfiles; // import org.springframework.test.util.ReflectionTestUtils; @@ -20,9 +24,12 @@ // import backend.techeerzip.domain.todayCs.entity.CsAnswer; // import backend.techeerzip.domain.todayCs.entity.CsProblem; // import backend.techeerzip.domain.todayCs.event.CsGradingResult; +// import backend.techeerzip.domain.todayCs.exception.CsProblemNotFoundException; // import backend.techeerzip.domain.todayCs.repository.answer.CsAnswerRepository; // import backend.techeerzip.domain.todayCs.repository.problem.CsProblemRepository; +// import backend.techeerzip.domain.todayCs.scheduler.TodayCsScheduler; // import backend.techeerzip.domain.user.entity.User; +// import backend.techeerzip.infra.slack.event.SlackEvent; // import lombok.extern.slf4j.Slf4j; // // @Slf4j @@ -35,8 +42,12 @@ // // @MockBean private CsAnswerRepository answerRepository; // +// @MockBean private ApplicationEventPublisher eventPublisher; // +// // @Autowired private TodayCsService todayCsService; // +// @Autowired private TodayCsScheduler todayCsScheduler; // +// // private CsProblem csProblem; // private User user; // @@ -82,4 +93,41 @@ // when(answerRepository.findById(any())).thenReturn(Optional.of(answer)); // todayCsService.updateCsAnswerGrading(1L, new CsGradingResult("test", 100)); // } +// +// @Test +// void todayCsPublishScheduler_성공() { +// // given +// when(problemRepository.findNextPublishProblem()).thenReturn(Optional.of(csProblem)); +// +// // when +// todayCsScheduler.TodayCsPublishScheduler(); +// +// // then +// verify(todayCsService, times(1)).publishCsProblem(null); +// } +// +// @Test +// void todayCsPublishScheduler_CsProblemNotFoundException_발생() { +// // given +// doThrow(new CsProblemNotFoundException()).when(todayCsService).publishCsProblem(null); +// +// // when +// todayCsScheduler.TodayCsPublishScheduler(); +// +// // then +// verify(eventPublisher, times(1)).publishEvent(any(SlackEvent.Channel.class)); +// } +// +// @Test +// void todayCsPublishScheduler_일반예외_발생() { +// // given +// final String errorMessage = "데이터베이스 연결 오류"; +// doThrow(new RuntimeException(errorMessage)).when(todayCsService).publishCsProblem(null); +// +// // when +// todayCsScheduler.TodayCsPublishScheduler(); +// +// // then +// verify(eventPublisher, times(1)).publishEvent(any(SlackEvent.Channel.class)); +// } // } diff --git a/techeerzip/src/test/resources/application-test.properties b/techeerzip/src/test/resources/application-test.properties new file mode 100644 index 00000000..16e2b499 --- /dev/null +++ b/techeerzip/src/test/resources/application-test.properties @@ -0,0 +1,139 @@ +spring.application.name=techeerzip +# Server Configuration +server.port=8000 +# Database Configuration +spring.datasource.url=jdbc:postgresql://dummy-host:dummy-port/dummy-db +spring.datasource.username=dummy-user +spring.datasource.password=dummy-password +spring.datasource.driver-class-name=org.postgresql.Driver + +# Multi-part Configuration +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB +spring.servlet.multipart.file-size-threshold=0B + +# JPA Configuration +spring.jpa.hibernate.ddl-auto=none +spring.jpa.open-in-view=false +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.properties.hibernate.use_sql_comments=false +spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul +spring.datasource.hikari.connectionInitSql=SET TIME ZONE 'Asia/Seoul' +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl +spring.jpa.properties.hibernate.globally_quoted_identifiers=true + +# Redis Configuration +spring.data.redis.host=dummy-redis-host +spring.data.redis.port=9999 +spring.data.redis.password=dummy-redis-password + +# JWT Configuration +jwt.secret=techeerzip0test1secret2key3value4must5be6long7enough8for9security1policy1check + +# AWS S3 Configuration +aws.s3.access-key=dummy-access-key +aws.s3.secret-key=dummy-secret-key +aws.s3.region=dummy-region +aws.s3.bucket-name=dummy-bucket + +# Email Configuration +spring.mail.host=dummy-smtp-host +spring.mail.port=9999 +spring.mail.username=dummy@dummy.com +spring.mail.password=dummy-mail-password +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# Email User (for AuthService) +EMAIL_USER=dummy@dummy.com + +# Email Pass (for application.properties placeholder) +EMAIL_PASS=dummy-mail-password + +# Google Drive Configuration +google.auth.type=service_account +google.auth.project-id=dummy-project-id +google.auth.private-key-id=dummy-private-key-id +google.auth.private-key=-----BEGIN PRIVATE KEY-----\ndummy-private-key-content\n-----END PRIVATE KEY----- +google.auth.client-email=dummy@dummy.com +google.auth.client-id=dummy-client-id +google.folder.id=dummy-folder-id +google.archive.folder.id=dummy-archive-folder-id + +# RabbitMQ Configuration +spring.rabbitmq.addresses=amqp://dummy-user:dummy-pass@dummy-host:9999 + +# GitHub API Configuration +github.api.token=dummy-token + +# Logging Configuration +spring.profiles.active=test +spring.logger.level=INFO +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.type.descriptor.sql=TRACE +logging.level.org.springframework.transaction=TRACE + +# Swagger Configuration +springdoc.swagger-ui.path=/dummy +springdoc.api-docs.path=/api/v3/api-docs +springdoc.swagger-ui.operationsSorter=method +springdoc.swagger-ui.tagsSorter=alpha +springdoc.swagger-ui.tryItOutEnabled=true + +# Swagger Authentication +swagger.username=dummy-swagger-user +swagger.password=dummy-swagger-pass + +https.server.url=http://dummy-server +staging.server.url=http://dummy-server + +# X_API_KEY (for AuthService, HttpClient, S3Controller) +X_API_KEY=dummy-key + +# PROFILE_IMG_URL (for AuthService) +PROFILE_IMG_URL=http://dummy-host/dummy-img.png + +# INDEX_API_URL (for IndexEventHandler) +INDEX_API_URL=http://dummy-host/dummy-api/index + +slack.environment=test + +slack.channel-url=https://hooks.slack.com/services/dummy/url +slack.dm-url=https://hooks.slack.com/services/dummy/url +slack.profile-img-url=http://dummy-host/dummy-img.png +slack.message-url=https://hooks.slack.com/services/dummy/url + +slack.today-cs-id=dummy-channel-id +slack.blog-challenge-id=dummy-channel-id +slack.emergency-alert-id=dummy-channel-id + +# Flyway +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=true +spring.flyway.locations=classpath:db/migration +spring.flyway.validate-on-migrate=true + +# Flyway Log +logging.level.org.flywaydb=DEBUG + +# PostgreSQL Lock +spring.flyway.postgresql.transactional.lock=true + +# Zoom API Configuration +zoom.api.base-url=https://dummy-api.dummy.com/v2 +zoom.api.account-id=dummy-account +zoom.api.client-id=dummy-client +zoom.api.client-secret=dummy-secret +zoom.webhook.secret-token=dummy-token +zoom.webhook.verification-token=dummy-token +zoom.meetings.default=dummy-meeting-id + +# Actuator +management.endpoints.web.exposure.include=prometheus +management.endpoint.prometheus.enabled=true +management.prometheus.metrics.export.enabled=true + +# Pyroscope Configuration +pyroscope.server.url=http://dummy-host:9999