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/mapper/BootcampMapper.java b/techeerzip/src/main/java/backend/techeerzip/domain/bootcamp/mapper/BootcampMapper.java index 4cf7acfa..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 @@ -95,6 +95,8 @@ public static BootcampListResponse toListResponseWithUserGeneration( 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()) diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamController.java b/techeerzip/src/main/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamController.java index 6794ec78..d9cb591c 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamController.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamController.java @@ -52,6 +52,7 @@ public ResponseEntity getDetail(@PathVariable Long pr @Override @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'MENTOR', 'TECHEER')") public ResponseEntity createProjectTeam( @RequestPart(value = "mainImage") MultipartFile mainImage, @RequestPart(value = "resultImages", required = false) List resultImages, @@ -63,6 +64,7 @@ public ResponseEntity createProjectTeam( } @PatchMapping(value = "/{projectTeamId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasPermission(#projectTeamId, 'ProjectTeam', 'UPDATE')") public ResponseEntity updateProjectTeam( @PathVariable Long projectTeamId, @RequestPart(value = "mainImage", required = false) MultipartFile mainImage, @@ -99,6 +101,7 @@ public ResponseEntity closeRecruit( } @PatchMapping("/delete/{projectTeamId}") + @PreAuthorize("hasPermission(#projectTeamId, 'ProjectTeam', 'DELETE')") public ResponseEntity deleteProjectTeam( @PathVariable Long projectTeamId, @CurrentUser CustomUserPrincipal currentUser) { log.info( @@ -110,6 +113,7 @@ public ResponseEntity deleteProjectTeam( } @GetMapping("/{projectTeamId}/applicants") + @PreAuthorize("hasPermission(#projectTeamId, 'ProjectTeam', 'READ')") public ResponseEntity> getApplicants( @PathVariable Long projectTeamId, @CurrentUser Long userId) { log.info( @@ -144,6 +148,7 @@ public ResponseEntity cancelApplication( } @PatchMapping("/accept") + @PreAuthorize("hasPermission(#request.teamId(), 'ProjectTeam', 'UPDATE')") public ResponseEntity acceptApplicant( @RequestBody ProjectApplicantRequest request, @CurrentUser CustomUserPrincipal currentUser) { @@ -154,6 +159,7 @@ public ResponseEntity acceptApplicant( } @PatchMapping("/reject") + @PreAuthorize("hasPermission(#request.teamId(), 'ProjectTeam', 'UPDATE')") public ResponseEntity rejectApplicant( @RequestBody ProjectApplicantRequest request, @CurrentUser CustomUserPrincipal currentUser) { diff --git a/techeerzip/src/main/java/backend/techeerzip/domain/stack/controller/StackController.java b/techeerzip/src/main/java/backend/techeerzip/domain/stack/controller/StackController.java index 6bde69e7..d86672ba 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/stack/controller/StackController.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/stack/controller/StackController.java @@ -3,6 +3,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import backend.techeerzip.domain.stack.dto.StackDto; @@ -16,6 +17,7 @@ public class StackController { private final StackService stackService; @PostMapping + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createStack(@RequestBody StackDto.Create request) { stackService.create(request); return ResponseEntity.ok().build(); 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 index beb6ff57..df360e2c 100644 --- a/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java +++ b/techeerzip/src/main/java/backend/techeerzip/domain/todayCs/scheduler/TodayCsScheduler.java @@ -34,8 +34,7 @@ public void todayCsPublishScheduler() { String.format( "[ERROR] CS 문제 발행 중 오류 발생!\n> 원인: %s\n> 시간: %s", e.getMessage(), java.time.LocalDateTime.now()); - eventPublisher.publishEvent( - new SlackEvent.Channel(errorMessage, SlackChannelType.EA)); + eventPublisher.publishEvent(new SlackEvent.Channel(errorMessage, SlackChannelType.EA)); } } } 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/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/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 6949bc4a..2357b235 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/exception/ErrorCode.java @@ -225,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/ProjectTeamPermissionEvaluator.java b/techeerzip/src/main/java/backend/techeerzip/global/permission/ProjectTeamPermissionEvaluator.java index 659d6f01..4dd3e5ec 100644 --- a/techeerzip/src/main/java/backend/techeerzip/global/permission/ProjectTeamPermissionEvaluator.java +++ b/techeerzip/src/main/java/backend/techeerzip/global/permission/ProjectTeamPermissionEvaluator.java @@ -62,7 +62,7 @@ public boolean hasPermission( private boolean isSupportedPermission(String permission) { return switch (permission) { - case "CLOSE" -> true; // 나중에 permission 추가 시 수정 + case "CLOSE", "UPDATE", "DELETE", "READ" -> true; default -> false; }; } 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/db/migration/V17__add_is_main_to_team_stack.sql b/techeerzip/src/main/resources/db/migration/V17__add_is_main_to_team_stack.sql new file mode 100644 index 00000000..96cd8e0c --- /dev/null +++ b/techeerzip/src/main/resources/db/migration/V17__add_is_main_to_team_stack.sql @@ -0,0 +1,3 @@ +-- TeamStack 테이블에 isMain 컬럼 추가 + +ALTER TABLE "TeamStack" ADD COLUMN IF NOT EXISTS "isMain" boolean NOT NULL DEFAULT false; 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 index 8a4e8515..f8258ad4 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectMember/service/ProjectMemberServiceTest.java @@ -305,8 +305,9 @@ void acceptApplicantThrowsExceptionWhenNotFound() { @DisplayName("실패: 이미 APPROVED 상태인 경우 PENDING으로 조회되지 않아 예외가 발생한다") void acceptApplicantThrowsExceptionWhenAlreadyApproved() { // Given - given(projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING)) + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) .willReturn(Optional.empty()); // When & Then @@ -314,8 +315,8 @@ void acceptApplicantThrowsExceptionWhenAlreadyApproved() { .isInstanceOf(ProjectMemberNotFoundException.class); // Verify: 서비스가 실제로 'PENDING' 상태를 조건으로 조회를 시도했는지 검증 (이 부분이 추가되어 중복 해결) - verify(projectMemberRepository).findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING); + verify(projectMemberRepository) + .findByProjectTeamIdAndUserIdAndStatus(teamId, userId, StatusCategory.PENDING); } } @@ -364,8 +365,9 @@ void rejectApplicantThrowsExceptionWhenNotFound() { @DisplayName("실패: 이미 REJECT 상태인 경우 PENDING으로 조회되지 않아 예외가 발생한다") void rejectApplicantThrowsExceptionWhenAlreadyRejected() { // Given - given(projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING)) + given( + projectMemberRepository.findByProjectTeamIdAndUserIdAndStatus( + teamId, userId, StatusCategory.PENDING)) .willReturn(Optional.empty()); // When & Then @@ -373,8 +375,8 @@ void rejectApplicantThrowsExceptionWhenAlreadyRejected() { .isInstanceOf(ProjectMemberNotFoundException.class); // Verify: PENDING 상태로 조회를 시도했는지 호출 여부를 검증하여 코드 중복 해결 - verify(projectMemberRepository).findByProjectTeamIdAndUserIdAndStatus( - teamId, userId, StatusCategory.PENDING); + verify(projectMemberRepository) + .findByProjectTeamIdAndUserIdAndStatus(teamId, userId, StatusCategory.PENDING); } } diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamControllerTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamControllerTest.java new file mode 100644 index 00000000..1fb5f380 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/controller/ProjectTeamControllerTest.java @@ -0,0 +1,302 @@ +package backend.techeerzip.domain.projectTeam.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +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.AutoConfigureMockMvc; +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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.techeerzip.domain.auth.jwt.CustomUserPrincipal; +import backend.techeerzip.domain.projectMember.dto.ProjectMemberApplicantResponse; +import backend.techeerzip.domain.projectTeam.dto.request.*; +import backend.techeerzip.domain.projectTeam.dto.response.GetAllTeamsResponse; +import backend.techeerzip.domain.projectTeam.dto.response.ProjectTeamDetailResponse; +import backend.techeerzip.domain.projectTeam.dto.response.SliceNextCursor; +import backend.techeerzip.domain.projectTeam.exception.ProjectTeamNotFoundException; +import backend.techeerzip.domain.projectTeam.service.ProjectTeamFacadeService; +import backend.techeerzip.global.logger.CustomLogger; + +@WebMvcTest(ProjectTeamController.class) +@AutoConfigureMockMvc(addFilters = false) +public class ProjectTeamControllerTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @MockBean private ProjectTeamFacadeService projectTeamFacadeService; + + @MockBean private CustomLogger customLogger; + + @BeforeEach + void setUp() { + CustomUserPrincipal mockPrincipal = mock(CustomUserPrincipal.class); + given(mockPrincipal.getUserId()).willReturn(1L); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(mockPrincipal, "", Collections.emptyList()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @Test + @DisplayName("성공: 프로젝트 팀 상세 조회") + void getDetail() throws Exception { + // Given + Long teamId = 1L; + ProjectTeamDetailResponse response = + ProjectTeamDetailResponse.builder().id(teamId).name("Test Team").build(); + + given(projectTeamFacadeService.getDetail(teamId)).willReturn(response); + + // When & Then + mockMvc.perform( + get("/api/v3/projectTeams/{projectTeamId}", teamId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(teamId)) + .andExpect(jsonPath("$.name").value("Test Team")); + } + + @Test + @DisplayName("성공: 프로젝트 팀 생성 (Multipart)") + void createProjectTeam() throws Exception { + // Given + MockMultipartFile mainImage = + new MockMultipartFile( + "mainImage", + "main.jpg", + MediaType.IMAGE_JPEG_VALUE, + "image data".getBytes()); + MockMultipartFile resultImage = + new MockMultipartFile( + "resultImages", + "result.jpg", + MediaType.IMAGE_JPEG_VALUE, + "result data".getBytes()); + + ProjectTeamCreateRequest requestDto = new ProjectTeamCreateRequest(); + String requestJson = objectMapper.writeValueAsString(requestDto); + MockMultipartFile requestPart = + new MockMultipartFile( + "createProjectTeamRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + requestJson.getBytes(StandardCharsets.UTF_8)); + + given(projectTeamFacadeService.create(any(), any(), any())).willReturn(1L); + + // When & Then + mockMvc.perform( + multipart("/api/v3/projectTeams") + .file(mainImage) + .file(resultImage) + .file(requestPart) + .with(csrf()) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andExpect(content().string("1")); + } + + @Test + @DisplayName("성공: 프로젝트 팀 수정 (PATCH + Multipart)") + void updateProjectTeam() throws Exception { + // Given + Long teamId = 1L; + + MockMultipartFile mainImage = + new MockMultipartFile( + "mainImage", + "updated.jpg", + MediaType.IMAGE_JPEG_VALUE, + "new data".getBytes()); + + ProjectTeamUpdateRequest requestDto = new ProjectTeamUpdateRequest(); + String requestJson = objectMapper.writeValueAsString(requestDto); + MockMultipartFile requestPart = + new MockMultipartFile( + "updateProjectTeamRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + requestJson.getBytes(StandardCharsets.UTF_8)); + + // setUp()에서 등록한 Principal이 자동으로 주입됨 + given( + projectTeamFacadeService.update( + eq(teamId), any(CustomUserPrincipal.class), any(), any(), any())) + .willReturn(teamId); + + // When & Then + mockMvc.perform( + multipart(HttpMethod.PATCH, "/api/v3/projectTeams/{projectTeamId}", teamId) + .file(mainImage) + .file(requestPart) + .with(csrf()) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andExpect(content().string("1")); + } + + @Test + @DisplayName("성공: 전체 팀 목록 조회") + void getAllTeams() throws Exception { + // Given + GetAllTeamsResponse response = + new GetAllTeamsResponse( + List.of(), SliceNextCursor.builder().hasNext(false).build()); + given(projectTeamFacadeService.getAllProjectAndStudyTeams(any(GetTeamsQueryRequest.class))) + .willReturn(response); + + // When & Then + mockMvc.perform( + get("/api/v3/projectTeams/allTeams") + .param("limit", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nextInfo.hasNext").value(false)); // nextInfo로 수정됨 (Good!) + } + + @Test + @DisplayName("성공: 프로젝트 팀 모집 종료") + void closeRecruit() throws Exception { + // Given + Long teamId = 1L; + doNothing().when(projectTeamFacadeService).closeRecruit(anyLong(), any()); + + // When & Then + mockMvc.perform( + patch("/api/v3/projectTeams/close/{projectTeamId}", teamId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("성공: 프로젝트 팀 삭제") + void deleteProjectTeam() throws Exception { + // Given + Long teamId = 1L; + doNothing() + .when(projectTeamFacadeService) + .softDeleteTeam(eq(teamId), any(CustomUserPrincipal.class)); + + // When & Then + mockMvc.perform(patch("/api/v3/projectTeams/delete/{projectTeamId}", teamId).with(csrf())) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("성공: 지원자 목록 조회") + void getApplicants() throws Exception { + // Given + Long teamId = 1L; + List applicants = List.of(); + given(projectTeamFacadeService.getApplicants(anyLong(), any())).willReturn(applicants); + + // When & Then + mockMvc.perform(get("/api/v3/projectTeams/{projectTeamId}/applicants", teamId)) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + @Test + @DisplayName("성공: 프로젝트 지원") + void applyToProject() throws Exception { + // Given + ProjectTeamApplyRequest request = new ProjectTeamApplyRequest(1L, null, "Summary"); + doNothing().when(projectTeamFacadeService).applyToProject(any(), any()); + + // When & Then + mockMvc.perform( + post("/api/v3/projectTeams/apply") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("성공: 지원 취소") + void cancelApplication() throws Exception { + // Given + Long teamId = 1L; + doNothing().when(projectTeamFacadeService).cancelApplication(anyLong(), any()); + + // When & Then + mockMvc.perform(patch("/api/v3/projectTeams/{projectTeamId}/cancel", teamId).with(csrf())) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("성공: 지원자 수락") + void acceptApplicant() throws Exception { + // Given + ProjectApplicantRequest request = new ProjectApplicantRequest(1L, 2L); + doNothing() + .when(projectTeamFacadeService) + .acceptApplicant(any(), any(CustomUserPrincipal.class)); + + // When & Then + mockMvc.perform( + patch("/api/v3/projectTeams/accept") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("성공: 지원자 거절") + void rejectApplicant() throws Exception { + // Given + ProjectApplicantRequest request = new ProjectApplicantRequest(1L, 2L); + doNothing() + .when(projectTeamFacadeService) + .rejectApplicant(any(), any(CustomUserPrincipal.class)); + + // When & Then + mockMvc.perform( + patch("/api/v3/projectTeams/reject") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("실패: 존재하지 않는 팀 조회 시 예외가 발생한다") + void getDetail_notFound() throws Exception { + // Given + Long teamId = 999L; + given(projectTeamFacadeService.getDetail(teamId)) + .willThrow(new ProjectTeamNotFoundException()); + + // When & Then + mockMvc.perform( + get("/api/v3/projectTeams/{projectTeamId}", teamId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectMainImageTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectMainImageTest.java new file mode 100644 index 00000000..1858c4ed --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectMainImageTest.java @@ -0,0 +1,45 @@ +package backend.techeerzip.domain.projectTeam.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ProjectMainImageTest { + + @Test + @DisplayName("성공: ProjectMainImage가 정상적으로 생성된다") + void createProjectMainImage() { + // Given + ProjectTeam projectTeam = mock(ProjectTeam.class); + String imageUrl = "https://example.com/main.jpg"; + + // When + ProjectMainImage image = + ProjectMainImage.builder().imageUrl(imageUrl).projectTeam(projectTeam).build(); + + // Then + assertThat(image.getImageUrl()).isEqualTo(imageUrl); + assertThat(image.getProjectTeam()).isEqualTo(projectTeam); + assertThat(image.isDeleted()).isFalse(); + } + + @Test + @DisplayName("성공: delete() 호출 시 isDeleted가 true로 변경된다") + void delete() { + // Given + ProjectMainImage image = + ProjectMainImage.builder() + .imageUrl("url") + .projectTeam(mock(ProjectTeam.class)) + .build(); + + // When + image.delete(); + + // Then + assertThat(image.isDeleted()).isTrue(); + assertThat(image.getUpdatedAt()).isNotNull(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectResultImageTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectResultImageTest.java new file mode 100644 index 00000000..088c4c9b --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectResultImageTest.java @@ -0,0 +1,45 @@ +package backend.techeerzip.domain.projectTeam.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ProjectResultImageTest { + + @Test + @DisplayName("성공: ProjectResultImage가 정상적으로 생성된다") + void createProjectResultImage() { + // Given + ProjectTeam projectTeam = mock(ProjectTeam.class); + String imageUrl = "https://example.com/result.jpg"; + + // When + ProjectResultImage image = + ProjectResultImage.builder().imageUrl(imageUrl).projectTeam(projectTeam).build(); + + // Then + assertThat(image.getImageUrl()).isEqualTo(imageUrl); + assertThat(image.getProjectTeam()).isEqualTo(projectTeam); + assertThat(image.isDeleted()).isFalse(); + } + + @Test + @DisplayName("성공: delete() 호출 시 isDeleted가 true로 변경된다") + void delete() { + // Given + ProjectResultImage image = + ProjectResultImage.builder() + .imageUrl("url") + .projectTeam(mock(ProjectTeam.class)) + .build(); + + // When + image.delete(); + + // Then + assertThat(image.isDeleted()).isTrue(); + assertThat(image.getUpdatedAt()).isNotNull(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectTeamTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectTeamTest.java new file mode 100644 index 00000000..094d5327 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/ProjectTeamTest.java @@ -0,0 +1,214 @@ +package backend.techeerzip.domain.projectTeam.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.projectMember.entity.ProjectMember; +import backend.techeerzip.domain.projectTeam.type.TeamRole; +import backend.techeerzip.domain.user.entity.User; + +public class ProjectTeamTest { + + // 테스트용 ProjectTeam 생성 헬퍼 메서드 + private ProjectTeam createProjectTeam() { + return ProjectTeam.builder() + .name("테커 팀") + .isRecruited(true) + .isFinished(false) + .frontendNum(2) + .backendNum(2) + .devopsNum(1) + .fullStackNum(0) + .dataEngineerNum(0) + .githubLink("github") + .notionLink("notion") + .projectExplain("explain") + .recruitExplain("recruit") + .build(); + } + + @Test + @DisplayName("성공: ProjectTeam 생성 시 기본값(ViewCount, LikeCount)은 0이다") + void createDefaultValues() { + // When + ProjectTeam team = createProjectTeam(); + + // Then + assertThat(team.getViewCount()).isZero(); + assertThat(team.getLikeCount()).isZero(); + assertThat(team.isDeleted()).isFalse(); + } + + @Test + @DisplayName("성공: softDelete() 호출 시 삭제 상태로 변경된다") + void softDelete() { + // Given + ProjectTeam team = createProjectTeam(); + + // When + team.softDelete(); + + // Then + assertThat(team.isDeleted()).isTrue(); + assertThat(team.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("성공: close() 호출 시 모집 상태가 false로 변경된다") + void close() { + // Given + ProjectTeam team = createProjectTeam(); + + // When + team.close(); + + // Then + assertThat(team.isRecruited()).isFalse(); + } + + @Test + @DisplayName("성공: finish() 호출 시 종료 상태가 true로 변경된다") + void finish() { + // Given + ProjectTeam team = createProjectTeam(); + + // When + team.finish(); + + // Then + assertThat(team.isFinished()).isTrue(); + } + + @Test + @DisplayName("성공: increaseViewCount() 호출 시 조회수가 1 증가한다") + void increaseViewCount() { + // Given + ProjectTeam team = createProjectTeam(); + int initialViewCount = team.getViewCount(); + + // When + team.increaseViewCount(); + + // Then + assertThat(team.getViewCount()).isEqualTo(initialViewCount + 1); + } + + @Test + @DisplayName("성공: decreaseTeamRoleCount()는 해당 역할의 모집 인원을 1 감소시킨다") + void decreaseTeamRoleCount() { + // Given + ProjectTeam team = createProjectTeam(); // backendNum = 2 + + // When + team.decreaseTeamRoleCount(TeamRole.BACKEND); + + // Then + assertThat(team.getBackendNum()).isEqualTo(1); + } + + @Test + @DisplayName("성공: decreaseTeamRoleCount()는 0 이하로 내려가지 않는다") + void decreaseTeamRoleCountBoundary() { + // Given + ProjectTeam team = createProjectTeam(); + // fullStackNum은 초기값이 0임 + + // When + team.decreaseTeamRoleCount(TeamRole.FULLSTACK); + + // Then + assertThat(team.getFullStackNum()).isEqualTo(0); + } + + @Test + @DisplayName("성공: getTotalTeamRoleCount()는 모든 포지션 모집 인원의 합을 반환한다") + void getTotalTeamRoleCount() { + // Given + ProjectTeam team = createProjectTeam(); + // 2(FE) + 2(BE) + 1(DevOps) + 0 + 0 = 5 + + // When + int total = team.getTotalTeamRoleCount(); + + // Then + assertThat(total).isEqualTo(5); + } + + @Test + @DisplayName("성공: getLeaderEmails()는 리더인 멤버의 이메일만 반환한다") + void getLeaderEmails() { + // Given + ProjectTeam team = createProjectTeam(); + + // Mock User & Member (Leader) + User leaderUser = mock(User.class); + given(leaderUser.getEmail()).willReturn("leader@test.com"); + ProjectMember leader = mock(ProjectMember.class); + given(leader.isLeader()).willReturn(true); + given(leader.getUser()).willReturn(leaderUser); + + // Mock User & Member (Member) + User normalUser = mock(User.class); + given(normalUser.getEmail()).willReturn("member@test.com"); + ProjectMember member = mock(ProjectMember.class); + given(member.isLeader()).willReturn(false); // 리더 아님 + given(member.getUser()).willReturn(normalUser); + + team.addProjectMembers(List.of(leader, member)); + + // When + List emails = team.getLeaderEmails(); + + // Then + assertThat(emails).hasSize(1); + assertThat(emails.getFirst()).isEqualTo("leader@test.com"); + } + + @Test + @DisplayName("성공: 메인 이미지를 업데이트하면 기존 리스트가 초기화되고 새 이미지가 들어간다") + void updateMainImage() { + // Given + ProjectTeam team = createProjectTeam(); + ProjectMainImage oldImage = mock(ProjectMainImage.class); + team.addProjectMainImages(List.of(oldImage)); + + ProjectMainImage newImage = mock(ProjectMainImage.class); + + // When + team.updateMainImage(newImage); + + // Then + assertThat(team.getMainImages()).hasSize(1); + assertThat(team.getMainImages().getFirst()).isEqualTo(newImage); + } + + @Test + @DisplayName("성공: 결과 이미지를 ID Set으로 삭제할 수 있다") + void deleteResultImages() { + // Given + ProjectTeam team = createProjectTeam(); + + ProjectResultImage img1 = mock(ProjectResultImage.class); + given(img1.getId()).willReturn(1L); + + ProjectResultImage img2 = mock(ProjectResultImage.class); + given(img2.getId()).willReturn(2L); + + // Reflection을 통해 리스트에 직접 추가 (ArrayList는 mock이 안되므로) + team.addProjectResultImages(List.of(img1, img2)); + + // When + team.deleteResultImages(Set.of(1L)); + + // Then + assertThat(team.getResultImages()).hasSize(1); + assertThat(team.getResultImages().getFirst().getId()).isEqualTo(2L); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/TeamStackTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/TeamStackTest.java new file mode 100644 index 00000000..7407214a --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/TeamStackTest.java @@ -0,0 +1,49 @@ +package backend.techeerzip.domain.projectTeam.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.stack.entity.Stack; + +public class TeamStackTest { + + @Test + @DisplayName("성공: TeamStack이 정상적으로 생성된다") + void createTeamStack() { + // Given + ProjectTeam projectTeam = mock(ProjectTeam.class); + Stack stack = mock(Stack.class); + boolean isMain = true; + + // When + TeamStack teamStack = + TeamStack.builder().projectTeam(projectTeam).stack(stack).isMain(isMain).build(); + + // Then + assertThat(teamStack.getProjectTeam()).isEqualTo(projectTeam); + assertThat(teamStack.getStack()).isEqualTo(stack); + assertThat(teamStack.isMain()).isTrue(); + } + + @Test + @DisplayName("성공: updateIsMain() 호출 시 메인 스택 여부가 변경된다") + void updateIsMain() { + // Given + TeamStack teamStack = + TeamStack.builder() + .isMain(false) + .projectTeam(mock(ProjectTeam.class)) + .stack(mock(Stack.class)) + .build(); + + // When + teamStack.updateIsMain(true); + + // Then + assertThat(teamStack.isMain()).isTrue(); + assertThat(teamStack.getUpdatedAt()).isNotNull(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/TeamUnionViewTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/TeamUnionViewTest.java new file mode 100644 index 00000000..3f7d59db --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/entity/TeamUnionViewTest.java @@ -0,0 +1,34 @@ +package backend.techeerzip.domain.projectTeam.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import backend.techeerzip.domain.projectTeam.type.TeamType; + +public class TeamUnionViewTest { + + @Test + @DisplayName("성공: TeamUnionView는 필드 값을 읽을 수 있다") + void getterTest() { + // Given + TeamUnionView view = new TeamUnionView(); + UUID uuid = UUID.randomUUID(); + + // View는 생성자가 없으므로 ReflectionTestUtils로 값을 강제 주입하여 테스트 + ReflectionTestUtils.setField(view, "globalId", uuid); + ReflectionTestUtils.setField(view, "id", 100L); + ReflectionTestUtils.setField(view, "viewCount", 50); + ReflectionTestUtils.setField(view, "teamType", TeamType.PROJECT); + + // When & Then + assertThat(view.getGlobalId()).isEqualTo(uuid); + assertThat(view.getId()).isEqualTo(100L); + assertThat(view.getViewCount()).isEqualTo(50); + assertThat(view.getTeamType()).isEqualTo(TeamType.PROJECT); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectResultImageRepositoryTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectResultImageRepositoryTest.java deleted file mode 100644 index 88a51f56..00000000 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectResultImageRepositoryTest.java +++ /dev/null @@ -1,56 +0,0 @@ -// package backend.techeerzip.domain.projectTeam.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.test.autoconfigure.orm.jpa.DataJpaTest; -// import org.springframework.test.context.ActiveProfiles; -// -// import backend.techeerzip.domain.projectTeam.entity.ProjectResultImage; -// import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; -// -// @ActiveProfiles("test") -// @DataJpaTest -// class ProjectResultImageRepositoryTest { -// -// @Autowired private ProjectTeamRepository projectTeamRepository; -// @Autowired private ProjectResultImageRepository projectResultImageRepository; -// -// private ProjectTeam savedTeam; -// -// @BeforeEach -// void setup() { -// savedTeam = -// projectTeamRepository.save( -// ProjectTeam.builder() -// .projectExplain("") -// .name("name") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build()); -// } -// -// @Test -// void countByProjectTeamId() { -// final ProjectResultImage img1 = -// ProjectResultImage.builder().projectTeam(savedTeam).imageUrl("").build(); -// final ProjectResultImage img2 = -// ProjectResultImage.builder().projectTeam(savedTeam).imageUrl("").build(); -// -// projectResultImageRepository.saveAll(List.of(img1, img2)); -// -// Assertions.assertEquals( -// projectResultImageRepository.countByProjectTeamId(savedTeam.getId()), 2); -// } -// } diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamDslRepositoryImplTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamDslRepositoryImplTest.java new file mode 100644 index 00000000..0449cc65 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamDslRepositoryImplTest.java @@ -0,0 +1,129 @@ +package backend.techeerzip.domain.projectTeam.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.querydsl.core.types.dsl.BooleanExpression; + +import backend.techeerzip.domain.projectTeam.exception.ProjectTeamInvalidSortType; +import backend.techeerzip.domain.projectTeam.repository.querydsl.ProjectTeamDslRepositoryImpl; +import backend.techeerzip.domain.projectTeam.type.PositionNumType; +import backend.techeerzip.domain.projectTeam.type.SortType; + +public class ProjectTeamDslRepositoryImplTest { + + @Test + @DisplayName("성공: 조회수 순 정렬일 때 count와 id가 있으면 커서 조건식이 생성된다") + void countCursorConditionBuilder_viewCount_withCountAndId() { + // given + SortType sortType = SortType.VIEW_COUNT_DESC; + Integer count = 100; + Long id = 10L; + + // when + BooleanExpression expression = + ProjectTeamDslRepositoryImpl.countCursorConditionBuilder(sortType, count, id); + + // then + assertThat(expression).isNotNull(); + assertThat(expression.toString()) + .contains("projectTeam.viewCount") // 필드 기준 + .contains("projectTeam.id"); // ID 보조 정렬 조건 + } + + @Test + @DisplayName("성공: 좋아요 순 정렬일 때 count만 있으면 '작다(<)' 조건식이 생성된다") + void countCursorConditionBuilder_likeCount_onlyCount() { + // given + SortType sortType = SortType.LIKE_COUNT_DESC; + Integer count = 50; + Long id = null; + + // when + BooleanExpression expression = + ProjectTeamDslRepositoryImpl.countCursorConditionBuilder(sortType, count, id); + + // then + assertThat(expression).isNotNull(); + assertThat(expression.toString()) + .contains("projectTeam.likeCount") + .contains("<"); // lt 조건 포함 여부 정도만 확인 + } + + @Test + @DisplayName("성공: 커서 기준값(count)이 null이면 조건식 없이 null을 반환한다") + void countCursorConditionBuilder_nullCount_returnsNull() { + // given + SortType sortType = SortType.VIEW_COUNT_DESC; + Integer count = null; + Long id = 1L; + + // when + BooleanExpression expression = + ProjectTeamDslRepositoryImpl.countCursorConditionBuilder(sortType, count, id); + + // then + assertThat(expression).isNull(); + } + + @Test + @DisplayName("실패: Count 정렬 방식이 아니면 예외가 발생한다") + void countCursorConditionBuilder_invalidSort_throwsException() { + // given + SortType sortType = SortType.UPDATE_AT_DESC; // 날짜 기반 타입 + Integer count = 10; + Long id = 1L; + + // when & then + assertThatThrownBy( + () -> + ProjectTeamDslRepositoryImpl.countCursorConditionBuilder( + sortType, count, id)) + .isInstanceOf(ProjectTeamInvalidSortType.class); + } + + @Test + @DisplayName("성공: 포지션 조건이 null이면 필터링 없이 null을 반환한다") + void buildPositionFilter_null_returnsNull() { + // when + BooleanExpression expression = ProjectTeamDslRepositoryImpl.buildPositionFilter(null); + + // then + assertThat(expression).isNull(); + } + + @Test + @DisplayName("성공: 포지션 조건 리스트가 비어있으면 null을 반환한다") + void buildPositionFilter_empty_returnsNull() { + // when + BooleanExpression expression = ProjectTeamDslRepositoryImpl.buildPositionFilter(List.of()); + + // then + assertThat(expression).isNull(); + } + + @Test + @DisplayName("성공: 여러 포지션이 주어지면 OR 조건으로 결합된 쿼리 표현식을 반환한다") + void buildPositionFilter_multiPositions_returnsOrExpression() { + // given + // 실제 enum 값은 프로젝트 코드에 맞게 사용 (예: BACKEND, FRONTEND 등) + List positions = + List.of(PositionNumType.BACKEND, PositionNumType.FRONTEND); + + // when + BooleanExpression expression = ProjectTeamDslRepositoryImpl.buildPositionFilter(positions); + + // then + assertThat(expression).isNotNull(); + String exprString = expression.toString(); + // 포지션 enum이 갖는 필드 이름에 따라 아래 contains 부분은 필요 시 수정 + assertThat(exprString).contains("projectTeam"); + assertThat(exprString).contains(" > "); // gt(0) 조건이 들어간 형태 + // 여러 포지션이면 or로 묶이는지 정도는 확인 가능 + assertThat(exprString.toLowerCase()).contains("||"); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamRepositoryTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamRepositoryTest.java deleted file mode 100644 index 24600c1d..00000000 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamRepositoryTest.java +++ /dev/null @@ -1,93 +0,0 @@ -// package backend.techeerzip.domain.projectTeam.repository; -// -// import jakarta.persistence.EntityManager; -// -// import org.assertj.core.api.Assertions; -// import org.junit.jupiter.api.BeforeEach; -// 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.orm.jpa.DataJpaTest; -// import org.springframework.test.context.ActiveProfiles; -// import org.springframework.transaction.annotation.Transactional; -// -// import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; -// -// @ActiveProfiles("test") -// @DataJpaTest -// class ProjectTeamRepositoryTest { -// -// @Autowired private ProjectTeamRepository projectTeamRepository; -// -// @Autowired private EntityManager em; -// -// private ProjectTeam savedTeam; -// -// @BeforeEach -// void setup() { -// savedTeam = -// projectTeamRepository.save( -// ProjectTeam.builder() -// .projectExplain("") -// .name("name") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build()); -// } -// -// @Test -// void existsByNameTrue() { -// Assertions.assertThat(projectTeamRepository.existsByName("name")).isTrue(); -// } -// -// @Test -// @Transactional -// void checkViewCountIncrease() { -// final ProjectTeam pm = projectTeamRepository.findById(savedTeam.getId()).orElseThrow(); -// pm.increaseViewCount(); -// em.flush(); -// em.clear(); -// -// Assertions.assertThat(projectTeamRepository.findById(savedTeam.getId())).isPresent(); -// } -// -// @Nested -// class CreateTest { -// -// @Test -// void createProjectTeamEntity() { -// final ProjectTeam create = -// projectTeamRepository.save( -// ProjectTeam.builder() -// .projectExplain("") -// .name("create") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build()); -// Assertions.assertThat(create).isNotNull(); -// } -// -// @Test -// void findByIdProjectTeam() { -// ProjectTeam find = projectTeamRepository.findById(savedTeam.getId()).orElseThrow(); -// -// Assertions.assertThat(savedTeam.getId()).isEqualTo(find.getId()); -// } -// } -// } diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamStackRepositoryTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamStackRepositoryTest.java deleted file mode 100644 index ec6569ed..00000000 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/ProjectTeamStackRepositoryTest.java +++ /dev/null @@ -1,46 +0,0 @@ -// package backend.techeerzip.domain.projectTeam.repository; -// -// import org.junit.jupiter.api.BeforeEach; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -// import org.springframework.test.context.ActiveProfiles; -// -// import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; -// import backend.techeerzip.domain.stack.entity.Stack; -// import backend.techeerzip.domain.stack.entity.StackCategory; -// import backend.techeerzip.domain.stack.repository.StackRepository; -// -// @ActiveProfiles("test") -// @DataJpaTest -// class ProjectTeamStackRepositoryTest { -// -// @Autowired ProjectTeamStackRepository projectTeamStackRepository; -// @Autowired ProjectTeamRepository projectTeamRepository; -// @Autowired StackRepository stackRepository; -// -// private ProjectTeam savedTeam; -// private Stack savedStack; -// -// @BeforeEach -// void setup() { -// savedTeam = -// projectTeamRepository.save( -// ProjectTeam.builder() -// .projectExplain("") -// .name("name") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build()); -// savedStack = -// stackRepository.save( -// Stack.builder().category(StackCategory.BACKEND).name("").build()); -// } -// } diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/TeamUnionViewDslRepositoryImplTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/TeamUnionViewDslRepositoryImplTest.java new file mode 100644 index 00000000..f30609bf --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/TeamUnionViewDslRepositoryImplTest.java @@ -0,0 +1,61 @@ +package backend.techeerzip.domain.projectTeam.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import backend.techeerzip.domain.projectTeam.repository.querydsl.TeamUnionViewDslRepositoryImpl; + +public class TeamUnionViewDslRepositoryImplTest { + + @Test + @DisplayName("성공: size가 limit보다 작거나 같으면 그대로 반환") + void ensureMaxSize_whenSizeLessOrEqualLimit_returnsSameList() { + // given + List input = new ArrayList<>(List.of(1, 2, 3)); + Integer limit = 3; + + // when + List result = TeamUnionViewDslRepositoryImpl.ensureMaxSize(input, limit); + + // then + assertThat(result).hasSize(3); + assertThat(result).containsExactly(1, 2, 3); + // 동일 인스턴스인지까지 보고 싶으면: + assertThat(result).isSameAs(input); + } + + @Test + @DisplayName("성공: size가 limit보다 크면 0 ~ limit-1 까지만 잘라서 반환") + void ensureMaxSize_whenSizeGreaterThanLimit_returnsSubList() { + // given + List input = new ArrayList<>(List.of(1, 2, 3, 4, 5)); + Integer limit = 3; + + // when + List result = TeamUnionViewDslRepositoryImpl.ensureMaxSize(input, limit); + + // then + assertThat(result).hasSize(3); + assertThat(result).containsExactly(1, 2, 3); + // subList는 원본의 view라 isSameAs는 false일 수 있으니 생략 또는 분리 판단 + } + + @Test + @DisplayName("성공: limit이 0이면 항상 빈 리스트 반환") + void ensureMaxSize_whenLimitZero_returnsEmptyList() { + // given + List input = new ArrayList<>(List.of(1, 2, 3)); + Integer limit = 0; + + // when + List result = TeamUnionViewDslRepositoryImpl.ensureMaxSize(input, limit); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/querydsl/ProjectTeamDslRepositoryImplTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/querydsl/ProjectTeamDslRepositoryImplTest.java deleted file mode 100644 index db6ef7cc..00000000 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/repository/querydsl/ProjectTeamDslRepositoryImplTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// package backend.techeerzip.domain.projectTeam.repository.querydsl; -// -// import org.junit.jupiter.api.Test; -// -// class ProjectTeamDslRepositoryImplTest { -// -// @Test -// void sliceYoungTeam() {} -// -// @Test -// void findManyYoungTeamById() {} -// -// @Test -// void findAlertDataForLeader() {} -// -// @Test -// void findAllTeamsByUserId() {} -// } diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamFacadeServiceImplTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamFacadeServiceImplTest.java new file mode 100644 index 00000000..a4938b68 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamFacadeServiceImplTest.java @@ -0,0 +1,88 @@ +package backend.techeerzip.domain.projectTeam.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.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.context.ApplicationEventPublisher; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import backend.techeerzip.domain.projectTeam.dto.request.ProjectTeamCreateRequest; +import backend.techeerzip.domain.projectTeam.dto.response.ProjectTeamCreateResponse; +import backend.techeerzip.global.logger.CustomLogger; +import backend.techeerzip.infra.index.IndexEvent; +import backend.techeerzip.infra.s3.S3Service; + +@ExtendWith(MockitoExtension.class) +public class ProjectTeamFacadeServiceImplTest { + + @Mock private ProjectTeamService projectTeamService; + @Mock private S3Service s3Service; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private CustomLogger logger; // Logger Mocking 필요 + + @InjectMocks private ProjectTeamFacadeServiceImpl facadeService; + + @Test + @DisplayName("성공: 팀 생성 시 S3 업로드 -> DB 저장 -> 이벤트 발행이 순서대로 실행된다") + void create_success() { + // Given + MockMultipartFile mainImage = + new MockMultipartFile("main", "main.jpg", "image/jpeg", "data".getBytes()); + List resultImages = List.of(); + ProjectTeamCreateRequest request = mock(ProjectTeamCreateRequest.class); + + // S3 Mocking + given(s3Service.upload(any(), anyString(), anyString())).willReturn(List.of("s3-url")); + + // Service Mocking + ProjectTeamCreateResponse response = new ProjectTeamCreateResponse(1L, null, null); + given(projectTeamService.create(any(), any(), any())).willReturn(response); + + // When + Long resultId = facadeService.create(mainImage, resultImages, request); + + // Then + assertThat(resultId).isEqualTo(1L); + verify(s3Service).upload(any(), anyString(), anyString()); + verify(projectTeamService).create(any(), any(), any()); + verify(eventPublisher, times(1)).publishEvent(any(IndexEvent.Create.class)); + } + + @Test + @DisplayName("실패: DB 저장 실패 시 업로드된 S3 이미지를 삭제해야 한다") + void create_fail_rollback() { + // Given + MockMultipartFile mainImage = + new MockMultipartFile("main", "main.jpg", "image/jpeg", "data".getBytes()); + List resultImages = List.of(); + ProjectTeamCreateRequest request = mock(ProjectTeamCreateRequest.class); + + // 1. S3 업로드는 성공했다고 가정 + given(s3Service.upload(any(), anyString(), anyString())) + .willReturn(List.of("uploaded-url")); + + // 2. DB 저장에서 예외 발생 가정 + given(projectTeamService.create(any(), any(), any())) + .willThrow(new RuntimeException("DB Error")); + + // When & Then + assertThatThrownBy(() -> facadeService.create(mainImage, resultImages, request)) + .isInstanceOf(RuntimeException.class); + + // ⭐ 핵심: deleteMany가 호출되었는지 확인 + verify(s3Service, times(1)).deleteMany(anyList()); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamServiceTest.java index eb4ee24b..521cbf05 100644 --- a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamServiceTest.java +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/ProjectTeamServiceTest.java @@ -1,869 +1,125 @@ -// package backend.techeerzip.domain.projectTeam.service; -// -// import static org.assertj.core.api.Assertions.assertThat; -// import static org.junit.jupiter.api.Assertions.assertEquals; -// import static org.junit.jupiter.api.Assertions.assertNotNull; -// import static org.junit.jupiter.api.Assertions.assertThrows; -// import static org.mockito.Mockito.any; -// import static org.mockito.Mockito.doThrow; -// import static org.mockito.Mockito.mock; -// import static org.mockito.Mockito.verify; -// import static org.mockito.Mockito.when; -// -// import java.util.List; -// import java.util.Optional; -// -// import org.junit.jupiter.api.AfterEach; -// import org.junit.jupiter.api.Assertions; -// 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.MockedStatic; -// import org.mockito.Mockito; -// import org.mockito.junit.jupiter.MockitoExtension; -// import org.springframework.core.io.ClassPathResource; -// -// import backend.techeerzip.domain.projectMember.dto.ProjectMemberInfoRequest; -// import backend.techeerzip.domain.projectMember.entity.ProjectMember; -// import backend.techeerzip.domain.projectMember.exception.ProjectInvalidActiveRequester; -// import backend.techeerzip.domain.projectMember.exception.ProjectMemberNotFoundException; -// import backend.techeerzip.domain.projectMember.repository.ProjectMemberRepository; -// import backend.techeerzip.domain.projectMember.service.ProjectMemberService; -// import backend.techeerzip.domain.projectTeam.dto.request.GetProjectTeamsQuery; -// import backend.techeerzip.domain.projectTeam.dto.request.ProjectTeamApplyRequest; -// import backend.techeerzip.domain.projectTeam.dto.request.ProjectTeamCreateRequest; -// import backend.techeerzip.domain.projectTeam.dto.request.ProjectTeamUpdateRequest; -// import backend.techeerzip.domain.projectTeam.dto.request.RecruitCounts; -// import backend.techeerzip.domain.projectTeam.dto.request.SlackRequest; -// import backend.techeerzip.domain.projectTeam.dto.request.TeamData; -// import backend.techeerzip.domain.projectTeam.dto.request.TeamStackInfo; -// import backend.techeerzip.domain.projectTeam.dto.request.TeamStackInfo.WithStack; -// import backend.techeerzip.domain.projectTeam.dto.response.GetAllTeamsResponse; -// import backend.techeerzip.domain.projectTeam.dto.response.LeaderInfo; -// import backend.techeerzip.domain.projectTeam.dto.response.ProjectSliceTeamsResponse; -// import backend.techeerzip.domain.projectTeam.dto.response.ProjectTeamCreateResponse; -// import backend.techeerzip.domain.projectTeam.dto.response.ProjectTeamDetailResponse; -// import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; -// import backend.techeerzip.domain.projectTeam.exception.ProjectDuplicateTeamName; -// import backend.techeerzip.domain.projectTeam.exception.ProjectExceededResultImageException; -// import backend.techeerzip.domain.projectTeam.exception.ProjectInvalidProjectMemberException; -// import backend.techeerzip.domain.projectTeam.exception.ProjectTeamMissingLeaderException; -// import backend.techeerzip.domain.projectTeam.exception.ProjectTeamMissingUpdateMemberException; -// import backend.techeerzip.domain.projectTeam.exception.ProjectTeamNotFoundException; -// import backend.techeerzip.domain.projectTeam.exception.ProjectTeamPositionClosedException; -// import backend.techeerzip.domain.projectTeam.exception.ProjectTeamRecruitmentClosedException; -// import backend.techeerzip.domain.projectTeam.mapper.ProjectIndexMapper; -// import backend.techeerzip.domain.projectTeam.mapper.ProjectSlackMapper; -// import backend.techeerzip.domain.projectTeam.repository.ProjectResultImageRepository; -// import backend.techeerzip.domain.projectTeam.repository.ProjectTeamRepository; -// import backend.techeerzip.domain.projectTeam.repository.querydsl.ProjectTeamDslRepository; -// import backend.techeerzip.domain.projectTeam.type.TeamRole; -// import backend.techeerzip.domain.projectTeam.type.TeamType; -// import backend.techeerzip.domain.role.entity.Role; -// import backend.techeerzip.domain.stack.entity.Stack; -// import backend.techeerzip.domain.stack.entity.StackCategory; -// import backend.techeerzip.domain.user.entity.User; -// import backend.techeerzip.domain.user.repository.UserRepository; -// import backend.techeerzip.global.entity.StatusCategory; -// import backend.techeerzip.global.logger.CustomLogger; -// -// @ExtendWith(MockitoExtension.class) -// class ProjectTeamServiceTest { -// -// @Mock private ProjectMemberService projectMemberService; -// @Mock private ProjectTeamRepository projectTeamRepository; -// @Mock private ProjectTeamDslRepository projectTeamDslRepository; -// @Mock private ProjectMemberRepository projectMemberRepository; -// @Mock private ProjectResultImageRepository resultImageRepository; -// @Mock private TeamStackService teamStackService; -// @Mock private UserRepository userRepository; -// @Mock private CustomLogger logger; -// @InjectMocks private ProjectTeamService projectTeamService; -// -// private ProjectTeam mockTeam; -// private TeamData mockTeamData; -// private RecruitCounts mockRecruitCounts; -// private List mockTeamStacks; -// private List projectMemberInfoRequests; -// -// private MockedStatic slackMapperMock; -// -// @BeforeEach -// void setUp() { -// slackMapperMock = Mockito.mockStatic(ProjectSlackMapper.class); -// -// mockTeamData = -// TeamData.builder() -// .name("name") -// .githubLink("") -// .projectExplain("") -// .isRecruited(true) -// .isFinished(true) -// .build(); -// -// mockRecruitCounts = -// RecruitCounts.builder() -// .backendNum(1) -// .frontendNum(1) -// .fullStackNum(1) -// .devOpsNum(1) -// .dataEngineerNum(1) -// .build(); -// mockTeamStacks = List.of(new TeamStackInfo.WithName("", true)); -// projectMemberInfoRequests = -// List.of(new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND)); -// -// mockTeam = -// ProjectTeam.builder() -// .name("name") -// .projectExplain("pje") -// .frontendNum(1) -// .backendNum(1) -// .fullStackNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .isFinished(false) -// .isRecruited(true) -// .build(); -// final ClassPathResource image = new ClassPathResource("IMG_2326.jpg"); -// } -// -// @AfterEach -// void tearDown() { -// slackMapperMock.close(); // 반드시 닫아야 static mock이 글로벌 오염 안됨 -// } -// -// /* TestCase -// * 1. 팀 생성 성공 -// * 2. 중복 이름 발생시 ProjectDuplicateTeamName 예외 발생 -// * 3. 존재하는 회원이 아니면 ProjectInvalidProjectMemberException 예외 발생 -// * */ -// @Nested -// class CreateTest { -// -// private ProjectTeamCreateRequest mockCreateRequest; -// -// @BeforeEach -// void setup() { -// mockCreateRequest = -// ProjectTeamCreateRequest.builder() -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .recruitCounts(mockRecruitCounts) -// .build(); -// } -// -// @Test -// @DisplayName("프로젝트 생성에 성공한다.") -// void SuccessCreateProject() { -// final Stack mockStack = -// Stack.builder().name("name").category(StackCategory.BACKEND).build(); -// final TeamStackInfo.WithStack mockTeamStack = new WithStack(mockStack, true); -// final User mockUser = User.builder().name("name").email("name").build(); -// final User spyUser = Mockito.spy(mockUser); -// final List leaders = List.of(new LeaderInfo("name", "emil")); -// -// Mockito.when(spyUser.getId()).thenReturn(1L); -// when(projectTeamRepository.existsByName("name")).thenReturn(false); -// when(userRepository.findAllById(any())).thenReturn(List.of(spyUser)); -// when(teamStackService.create(any())).thenReturn(List.of(mockTeamStack)); -// -// final ProjectTeam spyTeam = Mockito.spy(mockTeam); -// Mockito.when(spyTeam.getId()).thenReturn(1L); -// when(projectTeamRepository.save(any())).thenReturn(spyTeam); -// -// when(projectMemberService.getLeaders(any())).thenReturn(leaders); -// -// ProjectTeamCreateResponse actualResponse = -// projectTeamService.create(List.of(""), List.of(""), mockCreateRequest); -// -// ProjectTeamCreateResponse expectedResponse = -// new ProjectTeamCreateResponse( -// 1L, -// ProjectSlackMapper.toChannelRequest(spyTeam, leaders), -// ProjectIndexMapper.toIndexRequest(spyTeam)); -// -// assertThat(actualResponse).usingRecursiveComparison().isEqualTo(expectedResponse); -// } -// -// @Test -// @DisplayName("존재하는 프로젝트 이름이면 예외 발생한다.") -// void duplicateNameThenThrow() { -// when(projectTeamRepository.existsByName("name")).thenReturn(true); -// assertThrows( -// ProjectDuplicateTeamName.class, -// () -> projectTeamService.create(List.of(""), List.of(""), mockCreateRequest)); -// } -// -// @Test -// @DisplayName("존재하는 프로젝트 이름이면 예외 발생한다.") -// void InvalidUsersThenThrow() { -// final Stack mockStack = -// Stack.builder().name("name").category(StackCategory.BACKEND).build(); -// final TeamStackInfo.WithStack mockTeamStack = new WithStack(mockStack, true); -// when(userRepository.findAllById(any())).thenReturn(List.of()); -// when(projectTeamRepository.existsByName("name")).thenReturn(false); -// when(teamStackService.create(any())).thenReturn(List.of(mockTeamStack)); -// -// assertThrows( -// ProjectInvalidProjectMemberException.class, -// () -> projectTeamService.create(List.of(""), List.of(""), mockCreateRequest)); -// } -// } -// -// @Nested -// class updateTest { -// -// ProjectMember pm; -// private ProjectTeamUpdateRequest mockUpdateRequest; -// private ProjectTeam mockPT; -// private User mockU; -// -// @BeforeEach -// void setup() { -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .deleteMainImages(List.of(1L)) -// .deleteMembers(List.of(1L)) -// .deleteResultImages(List.of(1L)) -// .build(); -// -// mockPT = -// ProjectTeam.builder() -// .projectExplain("") -// .name("name") -// .recruitExplain("") -// .notionLink("") -// .isRecruited(true) -// .githubLink("") -// .fullStackNum(1) -// .frontendNum(1) -// .devopsNum(1) -// .dataEngineerNum(1) -// .backendNum(1) -// .isFinished(false) -// .build(); -// 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 InvalidProjectMemberThenThrow() { -// List noLeaderProjectMemberInfoRequests = -// List.of( -// new ProjectMemberInfoRequest(1L, false, TeamRole.BACKEND), -// new ProjectMemberInfoRequest(2L, false, TeamRole.BACKEND)); -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(noLeaderProjectMemberInfoRequests) -// .deleteMainImages(List.of(1L)) -// .deleteMembers(List.of(1L)) -// .deleteResultImages(List.of(1L)) -// .build(); -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// -// assertThrows( -// ProjectTeamMissingLeaderException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// -// @Test -// void exceedResultImageThenThrow() { -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// when(projectTeamRepository.existsByName(any())).thenReturn(false); -// -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mockTeam)); -// when(resultImageRepository.countByProjectTeamId(any())).thenReturn(12); -// -// assertThrows( -// ProjectExceededResultImageException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// -// // 업데이트 되는 멤버가 중복이면 ProjectInvalidProjectMemberException -// @Test -// void duplicateUpdateMemberThenThrow() { -// projectMemberInfoRequests = -// List.of( -// new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND), -// new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND)); -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .deleteMainImages(List.of()) -// .deleteMembers(List.of()) -// .deleteResultImages(List.of()) -// .build(); -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// when(projectTeamRepository.existsByName(any())).thenReturn(false); -// -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mockTeam)); -// when(resultImageRepository.countByProjectTeamId(any())).thenReturn(1); -// -// assertThrows( -// ProjectInvalidProjectMemberException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// -// // 삭제되는 멤버가 중복이면 ProjectInvalidProjectMemberException -// @Test -// void duplicateDeleteMemberThenThrow() { -// projectMemberInfoRequests = -// List.of(new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND)); -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .deleteMainImages(List.of()) -// .deleteMembers(List.of(1L, 1L)) -// .deleteResultImages(List.of()) -// .build(); -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// when(projectTeamRepository.existsByName(any())).thenReturn(false); -// -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mockTeam)); -// when(resultImageRepository.countByProjectTeamId(any())).thenReturn(1); -// -// assertThrows( -// ProjectInvalidProjectMemberException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// -// // 삭제되는 멤버가 다시 삭제 되면 ProjectMemberNotFoundException -// @Test -// void alreadyDeletedMemberThenThrow() { -// projectMemberInfoRequests = -// List.of( -// new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND), -// new ProjectMemberInfoRequest(2L, true, TeamRole.BACKEND)); -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .deleteMainImages(List.of()) -// .deleteMembers(List.of(3L)) -// .deleteResultImages(List.of()) -// .build(); -// final ProjectMember spyPm = Mockito.spy(pm); -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// when(projectTeamRepository.existsByName(any())).thenReturn(false); -// -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mockTeam)); -// when(resultImageRepository.countByProjectTeamId(any())).thenReturn(1); -// Mockito.when(spyPm.isDeleted()).thenReturn(true); -// Mockito.when(spyPm.getId()).thenReturn(3L); -// -// when(projectMemberRepository.findAllByProjectTeamId(any())).thenReturn(List.of(spyPm)); -// -// assertThrows( -// ProjectMemberNotFoundException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// -// // 삭제되는 멤버와 업데이트 멤버가 중복되면 ProjectTeamDuplicateDeleteUpdateException -// @Test -// void duplicateUpdateDeleteMemberThenThrow() { -// projectMemberInfoRequests = -// List.of( -// new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND), -// new ProjectMemberInfoRequest(2L, true, TeamRole.BACKEND)); -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .deleteMainImages(List.of()) -// .deleteMembers(List.of(1L)) -// .deleteResultImages(List.of()) -// .build(); -// final ProjectMember spyPm = Mockito.spy(pm); -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// when(projectTeamRepository.existsByName(any())).thenReturn(false); -// -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mockTeam)); -// when(resultImageRepository.countByProjectTeamId(any())).thenReturn(1); -// Mockito.when(spyPm.isDeleted()).thenReturn(true); -// Mockito.when(spyPm.getId()).thenReturn(1L); -// -// when(projectMemberRepository.findAllByProjectTeamId(any())).thenReturn(List.of(spyPm)); -// -// assertThrows( -// ProjectMemberNotFoundException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// -// // 삭제되는 멤버가 existingMembers에 없으면 ProjectTeamMissingUpdateMemberException -// @Test -// void notExistDeleteMemberInExistingMemberThenThrow() { -// projectMemberInfoRequests = -// List.of( -// new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND), -// new ProjectMemberInfoRequest(2L, true, TeamRole.BACKEND)); -// mockUpdateRequest = -// ProjectTeamUpdateRequest.builder() -// .recruitCounts(mockRecruitCounts) -// .teamData(mockTeamData) -// .teamStacks(mockTeamStacks) -// .projectMember(projectMemberInfoRequests) -// .deleteMainImages(List.of()) -// .deleteMembers(List.of(3L)) -// .deleteResultImages(List.of()) -// .build(); -// -// final ProjectMember spyPm = Mockito.spy(pm); -// when(projectMemberRepository.existsByUserIdAndProjectTeamIdAndIsDeletedFalseAndStatus( -// any(), any(), any())) -// .thenReturn(true); -// when(projectTeamRepository.existsByName(any())).thenReturn(false); -// -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mockTeam)); -// when(resultImageRepository.countByProjectTeamId(any())).thenReturn(1); -// Mockito.when(spyPm.getId()).thenReturn(1L); -// -// when(projectMemberRepository.findAllByProjectTeamId(any())).thenReturn(List.of(spyPm)); -// -// assertThrows( -// ProjectTeamMissingUpdateMemberException.class, -// () -> -// projectTeamService.update( -// 1L, 1L, List.of(), List.of(), mockUpdateRequest)); -// } -// } -// -// @Test -// void applyRequestTest() { -// final ProjectTeamApplyRequest request = -// new ProjectTeamApplyRequest(1L, TeamRole.BACKEND, "summary"); -// assertThat(request.teamRole()).isEqualTo(TeamRole.BACKEND); -// } -// -// @Nested -// @DisplayName("apply 메서드") -// class ApplyTest { -// -// private ProjectTeam mt; -// private ProjectMember mpm; -// private ProjectTeamApplyRequest request; -// private User mu; -// private final List leaders = List.of(new LeaderInfo("name", "email")); -// -// @BeforeEach -// void setup() { -// mt = Mockito.mock(ProjectTeam.class); -// mpm = Mockito.mock(ProjectMember.class); -// request = new ProjectTeamApplyRequest(1L, TeamRole.BACKEND, "summary"); -// mu = Mockito.mock(User.class); -// } -// -// @Test -// @DisplayName("모집 중인 포지션이면 지원 성공") -// void applySuccess() { -// when(mt.isRecruited()).thenReturn(true); -// when(mt.isRecruitPosition(any())).thenReturn(true); -// when(projectTeamRepository.findById(any())).thenReturn(Optional.of(mt)); -// when(projectMemberService.applyApplicant(any(), any(), any(), any())).thenReturn(mpm); -// when(projectMemberService.getLeaders(any())).thenReturn(leaders); -// when(mpm.getUser()).thenReturn(mu); -// when(mu.getEmail()).thenReturn("applicant@email.com"); -// -// List result = projectTeamService.apply(request, 10L); -// -// assertNotNull(result); -// } -// -// @Test -// @DisplayName("모집이 종료된 팀이면 예외 발생") -// void applyToClosedRecruitmentThenThrow() { -// -// ProjectTeam team = Mockito.spy(mockTeam); -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.of(team)); -// when(team.isRecruited()).thenReturn(false); -// -// assertThrows( -// ProjectTeamRecruitmentClosedException.class, -// () -> projectTeamService.apply(request, 10L)); -// } -// -// @Test -// @DisplayName("포지션이 닫힌 경우 예외 발생") -// void applyToClosedPositionThenThrow() { -// ProjectTeam team = Mockito.spy(mockTeam); -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.of(team)); -// when(team.isRecruited()).thenReturn(true); -// when(team.isRecruitPosition(TeamRole.BACKEND)).thenReturn(false); -// -// assertThrows( -// ProjectTeamPositionClosedException.class, -// () -> projectTeamService.apply(request, 10L)); -// } -// -// @Test -// @DisplayName("존재하지 않는 팀이면 예외 발생") -// void applyToNonexistentTeamThenThrow() { -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.empty()); -// -// assertThrows( -// ProjectTeamNotFoundException.class, -// () -> projectTeamService.apply(request, 10L)); -// } -// } -// -// @Nested -// @DisplayName("cancelApplication 메서드 테스트") -// class CancelApplicationTest { -// private final List leaders = List.of(new LeaderInfo("name", "email")); -// -// @Test -// @DisplayName("지원 취소 성공") -// void cancelSuccess() { -// final Long teamId = 1L; -// final Long applicantId = 10L; -// final ProjectMember pm = Mockito.mock(ProjectMember.class); -// final User applicant = Mockito.mock(User.class); -// final ProjectTeam team = Mockito.mock(ProjectTeam.class); -// when(applicant.getEmail()).thenReturn("applicantEmail"); -// when(pm.getUser()).thenReturn(applicant); -// when(projectMemberRepository.findByProjectTeamIdAndUserId(teamId, applicantId)) -// .thenReturn(Optional.of(pm)); -// when(projectTeamRepository.findById(teamId)).thenReturn(Optional.ofNullable(team)); -// when(projectMemberService.getLeaders(any())).thenReturn(leaders); -// -// final List expectedSlackMessages = -// List.of( -// new SlackRequest.DM( -// 1L, -// TeamType.PROJECT, -// "teamName", -// "email", -// "applicantEmail", -// StatusCategory.CANCELLED)); -// slackMapperMock -// .when( -// () -> -// ProjectSlackMapper.toDmRequest( -// team, -// leaders, -// "applicantEmail", -// StatusCategory.CANCELLED)) -// .thenReturn(expectedSlackMessages); -// -// List result = -// projectTeamService.cancelApplication(teamId, applicantId); -// -// Assertions.assertEquals(expectedSlackMessages, result); -// } -// -// @Test -// @DisplayName("지원 정보가 존재하지 않으면 예외 발생") -// void cancelWithoutExistMemberThenThrow() { -// when(projectMemberRepository.findByProjectTeamIdAndUserId(1L, 100L)) -// .thenReturn(Optional.empty()); -// -// assertThrows( -// ProjectMemberNotFoundException.class, -// () -> projectTeamService.cancelApplication(1L, 100L)); -// } -// } -// -// @Nested -// @DisplayName("acceptApplicant 메서드 테스트") -// class AcceptApplicantTest { -// private final List leaders = List.of(new LeaderInfo("name", "email")); -// -// @Test -// @DisplayName("정상 요청이면 승인 성공") -// void acceptSuccess() { -// final Long teamId = 1L; -// final Long userId = 10L; -// final Long applicantId = 100L; -// final ProjectTeam team = mock(ProjectTeam.class); -// when(projectTeamRepository.findById(teamId)).thenReturn(Optional.of(team)); -// final ProjectMember pm = mock(ProjectMember.class); -// final User applicantUser = mock(User.class); -// when(applicantUser.getEmail()).thenReturn("applicantEmail"); -// when(pm.getUser()).thenReturn(applicantUser); -// when(projectMemberService.getLeaders(any())).thenReturn(leaders); -// when(projectMemberService.acceptApplicant(teamId, applicantId)) -// .thenReturn("applicantEmail"); -// -// final List expectedSlackMessages = -// List.of( -// new SlackRequest.DM( -// 1L, -// TeamType.PROJECT, -// "teamName", -// "email", -// "applicantEmail", -// StatusCategory.APPROVED)); -// slackMapperMock -// .when( -// () -> -// ProjectSlackMapper.toDmRequest( -// team, -// leaders, -// "applicantEmail", -// StatusCategory.APPROVED)) -// .thenReturn(expectedSlackMessages); -// -// List result = -// projectTeamService.acceptApplicant(teamId, userId, applicantId); -// -// Assertions.assertEquals(expectedSlackMessages, result); -// } -// -// @Test -// @DisplayName("권한 없는 사용자면 예외 발생") -// void notActiveMemberThenThrow() { -// final Long teamId = 1L; -// final Long userId = 10L; -// final Long applicantId = 100L; -// -// doThrow(new ProjectInvalidActiveRequester()) -// .when(projectMemberService) -// .checkActive(teamId, userId); -// assertThrows( -// ProjectInvalidActiveRequester.class, -// () -> projectTeamService.acceptApplicant(teamId, userId, applicantId)); -// } -// } -// -// @Nested -// @DisplayName("rejectApplicant 메서드 테스트") -// class RejectApplicantTest { -// private final List leaders = List.of(new LeaderInfo("name", "email")); -// -// @Test -// @DisplayName("정상 요청이면 거절 성공") -// void rejectSuccess() { -// final Long teamId = 1L; -// final Long userId = 10L; -// final Long applicantId = 100L; -// when(projectMemberService.getLeaders(any())).thenReturn(leaders); -// final ProjectTeam team = Mockito.mock(ProjectTeam.class); -// final User applicant = Mockito.mock(User.class); -// when(applicant.getEmail()).thenReturn("applicantEmail"); -// when(projectTeamRepository.findById(teamId)).thenReturn(Optional.of(team)); -// ProjectMember pm = Mockito.mock(ProjectMember.class); -// when(pm.getUser()).thenReturn(applicant); -// when(projectMemberService.rejectApplicant(any(), any())).thenReturn("applicantEmail"); -// -// final List expectedSlackMessages = -// List.of( -// new SlackRequest.DM( -// teamId, -// TeamType.PROJECT, -// "teamName", -// "email", -// "applicantEmail", -// StatusCategory.REJECT)); -// slackMapperMock -// .when( -// () -> -// ProjectSlackMapper.toDmRequest( -// team, leaders, "applicantEmail", -// StatusCategory.REJECT)) -// .thenReturn(expectedSlackMessages); -// -// Assertions.assertDoesNotThrow( -// () -> projectTeamService.rejectApplicant(teamId, userId, applicantId)); -// } -// -// @Test -// @DisplayName("권한 없는 사용자면 예외 발생") -// void rejectWithoutAuthorityThenThrow() { -// final Long teamId = 1L; -// final Long userId = 10L; -// final Long applicantId = 100L; -// -// doThrow(new ProjectInvalidActiveRequester()) -// .when(projectMemberService) -// .checkActive(teamId, userId); -// -// assertThrows( -// ProjectInvalidActiveRequester.class, -// () -> projectTeamService.rejectApplicant(teamId, userId, applicantId)); -// } -// } -// -// @Nested -// @DisplayName("updateViewCountAndGetDetail 테스트") -// class UpdateViewCountAndGetDetailTest { -// -// @Test -// void success() { -// mockTeam = mock(ProjectTeam.class); -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.of(mockTeam)); -// -// ProjectTeamDetailResponse response = -// projectTeamService.updateViewCountAndGetDetail(1L); -// assertNotNull(response); -// verify(mockTeam).increaseViewCount(); -// } -// -// @Test -// void notFoundThenThrow() { -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.empty()); -// assertThrows(Exception.class, () -> -// projectTeamService.updateViewCountAndGetDetail(1L)); -// } -// } -// -// @Nested -// @DisplayName("getYoungTeamsById 테스트") -// class GetYoungTeamsByIdTest { -// -// @Test -// void success() { -// when(projectTeamDslRepository.findManyYoungTeamById(any(), any(), any())) -// .thenReturn(List.of(mock(ProjectSliceTeamsResponse.class))); -// List result = -// projectTeamService.getYoungTeamsById(List.of(1L), true, false); -// assertEquals(1, result.size()); -// } -// } -// -// @Nested -// @DisplayName("getYoungTeams 테스트") -// class GetYoungTeamsTest { -// -// @Test -// void success() { -// when(projectTeamDslRepository.sliceYoungTeams(any())) -// .thenReturn(List.of(mock(ProjectTeam.class))); -// GetAllTeamsResponse result = -// projectTeamService.getYoungTeams(mock(GetProjectTeamsQuery.class)); -// } -// } -// -// @Nested -// @DisplayName("close 테스트") -// class CloseTest { -// -// @Test -// void success() { -// ProjectTeam team = mock(ProjectTeam.class); -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.of(team)); -// -// projectTeamService.close(1L, 10L); -// -// verify(team).close(); -// } -// -// @Test -// void notMemberThenThrow() { -// doThrow(new ProjectInvalidActiveRequester()) -// .when(projectMemberService) -// .checkActive(any(), any()); -// assertThrows( -// ProjectInvalidActiveRequester.class, () -> projectTeamService.close(1L, 10L)); -// } -// -// @Test -// void notFoundThenThrow() { -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.empty()); -// assertThrows( -// ProjectTeamNotFoundException.class, () -> projectTeamService.close(1L, 10L)); -// } -// } -// -// @Nested -// @DisplayName("softDelete 테스트") -// class SoftDeleteTest { -// -// @Test -// void success() { -// ProjectTeam team = mock(ProjectTeam.class); -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.of(team)); -// -// projectTeamService.softDelete(1L, 10L); -// -// verify(team).softDelete(); -// } -// -// @Test -// void notMemberThenThrow() { -// doThrow(new ProjectInvalidActiveRequester()) -// .when(projectMemberService) -// .checkActive(any(), any()); -// assertThrows( -// ProjectInvalidActiveRequester.class, -// () -> projectTeamService.softDelete(1L, 10L)); -// } -// -// @Test -// void notFoundThenThrow() { -// when(projectTeamRepository.findById(1L)).thenReturn(Optional.empty()); -// assertThrows( -// ProjectTeamNotFoundException.class, -// () -> projectTeamService.softDelete(1L, 10L)); -// } -// } -// } +package backend.techeerzip.domain.projectTeam.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.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.ProjectMemberInfoRequest; +import backend.techeerzip.domain.projectMember.service.ProjectMemberService; +import backend.techeerzip.domain.projectTeam.dto.request.ProjectTeamCreateRequest; +import backend.techeerzip.domain.projectTeam.dto.request.RecruitCounts; +import backend.techeerzip.domain.projectTeam.dto.request.TeamData; +import backend.techeerzip.domain.projectTeam.dto.response.ProjectTeamCreateResponse; +import backend.techeerzip.domain.projectTeam.entity.ProjectTeam; +import backend.techeerzip.domain.projectTeam.exception.ProjectDuplicateTeamName; +import backend.techeerzip.domain.projectTeam.exception.TeamInvalidRecruitNumException; +import backend.techeerzip.domain.projectTeam.repository.ProjectTeamRepository; +import backend.techeerzip.domain.projectTeam.type.TeamRole; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +public class ProjectTeamServiceTest { + + @Mock private ProjectTeamRepository projectTeamRepository; + @Mock private UserRepository userRepository; + @Mock private TeamStackService teamStackService; + @Mock private ProjectMemberService projectMemberService; + + @InjectMocks private ProjectTeamService projectTeamService; + + @Test + @DisplayName("성공: 모든 유효성 검사를 통과하면 팀이 생성된다") + void create_success() { + // Given + // 1. Request Mocking + ProjectTeamCreateRequest request = mock(ProjectTeamCreateRequest.class); + TeamData teamData = mock(TeamData.class); + RecruitCounts recruitCounts = mock(RecruitCounts.class); + + // 멤버 요청 정보 (userId: 1L) + ProjectMemberInfoRequest memberInfo = + new ProjectMemberInfoRequest(1L, true, TeamRole.BACKEND); + + when(request.getTeamData()).thenReturn(teamData); + when(request.getRecruitCounts()).thenReturn(recruitCounts); + when(request.getProjectMember()).thenReturn(List.of(memberInfo)); + + // 2. Data Setting + when(teamData.getName()).thenReturn("Unique Team"); + when(recruitCounts.getBackendNum()).thenReturn(1); // 총 모집인원 > 0 + + // 3. Repository & Service Mocking + given(projectTeamRepository.existsByName("Unique Team")).willReturn(false); // 중복 이름 없음 + + // [핵심 수정] 요청한 userId(1L)와 동일한 ID를 가진 Mock User를 반환하도록 설정 + User mockUser = mock(User.class); + given(mockUser.getId()).willReturn(1L); + given(userRepository.findAllById(any())).willReturn(List.of(mockUser)); + + // 저장될 팀 Mocking + ProjectTeam savedTeam = mock(ProjectTeam.class); + given(savedTeam.getId()).willReturn(100L); + given(projectTeamRepository.save(any(ProjectTeam.class))).willReturn(savedTeam); + + // When + ProjectTeamCreateResponse response = + projectTeamService.create(List.of("main.jpg"), List.of(), request); + + // Then + assertThat(response.id()).isEqualTo(100L); + } + + @Test + @DisplayName("실패: 이미 존재하는 팀 이름이면 예외가 발생한다") + void create_fail_duplicate_name() { + // Given + ProjectTeamCreateRequest request = mock(ProjectTeamCreateRequest.class); + TeamData teamData = mock(TeamData.class); + RecruitCounts recruitCounts = mock(RecruitCounts.class); + + when(request.getTeamData()).thenReturn(teamData); + when(request.getRecruitCounts()).thenReturn(recruitCounts); + + when(teamData.getName()).thenReturn("Duplicate Team"); + when(recruitCounts.getBackendNum()).thenReturn(1); + + // 이미 존재하는 이름이라고 설정 + given(projectTeamRepository.existsByName("Duplicate Team")).willReturn(true); + + // When & Then + assertThatThrownBy(() -> projectTeamService.create(List.of("main.jpg"), List.of(), request)) + .isInstanceOf(ProjectDuplicateTeamName.class); + } + + @Test + @DisplayName("실패: 총 모집 인원이 음수이면 예외가 발생한다") + void create_fail_invalid_recruit_count() { + // Given + ProjectTeamCreateRequest request = mock(ProjectTeamCreateRequest.class); + TeamData teamData = mock(TeamData.class); + RecruitCounts recruitCounts = mock(RecruitCounts.class); + + when(request.getTeamData()).thenReturn(teamData); + when(request.getRecruitCounts()).thenReturn(recruitCounts); + + // 음수 인원 설정 + when(recruitCounts.getBackendNum()).thenReturn(-1); + + // When & Then + assertThatThrownBy(() -> projectTeamService.create(List.of("main.jpg"), List.of(), request)) + .isInstanceOf(TeamInvalidRecruitNumException.class); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/TeamStackServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/TeamStackServiceTest.java new file mode 100644 index 00000000..4c7d63ee --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/projectTeam/service/TeamStackServiceTest.java @@ -0,0 +1,70 @@ +package backend.techeerzip.domain.projectTeam.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.projectTeam.dto.request.TeamStackInfo; +import backend.techeerzip.domain.projectTeam.exception.ProjectInvalidTeamStackException; +import backend.techeerzip.domain.stack.entity.Stack; +import backend.techeerzip.domain.stack.repository.StackRepository; + +@ExtendWith(MockitoExtension.class) +public class TeamStackServiceTest { + + @Mock private StackRepository stackRepository; + @InjectMocks private TeamStackService teamStackService; + + @Test + @DisplayName("성공: 요청한 스택 이름이 모두 존재하면 정상 반환한다") + void create_success() { + // Given + TeamStackInfo.WithName requestStack = new TeamStackInfo.WithName("Java", true); + List requests = List.of(requestStack); + + Stack stack = mock(Stack.class); + when(stack.getName()).thenReturn("Java"); + + given(stackRepository.findAllByNameIn(anyCollection())).willReturn(List.of(stack)); + + // When + List result = teamStackService.create(requests); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getStack()).isEqualTo(stack); + assertThat(result.get(0).getIsMain()).isTrue(); + } + + @Test + @DisplayName("실패: 요청한 스택 중 하나라도 DB에 없으면 예외가 발생한다") + void create_fail_invalid_stack() { + // Given + // 요청은 2개 ("Java", "Unknown") + List requests = + List.of( + new TeamStackInfo.WithName("Java", true), + new TeamStackInfo.WithName("Unknown", false)); + + Stack stack = mock(Stack.class); + + // DB에는 "Java" 하나만 있다고 가정 + given(stackRepository.findAllByNameIn(anyCollection())).willReturn(List.of(stack)); + + // When & Then + assertThatThrownBy(() -> teamStackService.create(requests)) + .isInstanceOf(ProjectInvalidTeamStackException.class); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/stack/controller/StackControllerTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/stack/controller/StackControllerTest.java new file mode 100644 index 00000000..b7290f2d --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/stack/controller/StackControllerTest.java @@ -0,0 +1,142 @@ +package backend.techeerzip.domain.stack.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +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.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.stack.dto.StackDto; +import backend.techeerzip.domain.stack.entity.StackCategory; +import backend.techeerzip.domain.stack.service.StackService; + +@WebMvcTest(StackController.class) // Controller 계층 테스트 전용 어노테이션 +@Import(StackControllerTest.TestSecurityConfig.class) +public class StackControllerTest { + + @Autowired private MockMvc mockMvc; // 가짜 HTTP 요청 도구 + + @Autowired private ObjectMapper objectMapper; // 객체 <-> JSON 변환 도구 + + @MockBean private StackService stackService; // Service는 가짜 객체로 대체 + + // 테스트용 Method Security 설정 + @TestConfiguration + @EnableMethodSecurity + static class TestSecurityConfig {} + + @Test + @DisplayName("성공: 스택 생성 API 테스트") + @WithMockUser(roles = "ADMIN") + void createStack() throws Exception { + // Given + StackDto.Create request = new StackDto.Create("Java", "BACKEND"); + + // When & Then + mockMvc.perform( + post("/api/v3/stacks") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); // 200 OK 확인 + } + + @Test + @DisplayName("실패: ADMIN 권한 없는 사용자의 스택 생성 거부") + @WithMockUser(roles = "USER") + void createStackUnauthorized() throws Exception { + // Given + StackDto.Create request = new StackDto.Create("Java", "BACKEND"); + + // When & Then + mockMvc.perform( + post("/api/v3/stacks") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); // 403 Forbidden + } + + @Test + @DisplayName("성공: 스택 전체 조회 API 테스트") + @WithMockUser + void getStacksByCategory() throws Exception { + // Given + StackDto.Response response = + StackDto.Response.builder() + .id(1L) + .name("Spring") + .category(StackCategory.BACKEND) + .build(); + + // Service가 리스트를 반환하도록 설정 + given(stackService.getAll()).willReturn(List.of(response)); + + // When & Then + mockMvc.perform(get("/api/v3/stacks").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Spring")) // JSON 응답 검증 + .andExpect(jsonPath("$[0].category").value("BACKEND")); + } + + @Test + @DisplayName("실패: 잘못된 카테고리 값으로 스택 생성 시 실패한다.") + @WithMockUser(roles = "ADMIN") + void createStackWithInvalidCategory() throws Exception { + // Given + StackDto.Create request = new StackDto.Create("Java", "INVALID_CATEGORY"); + + // Service에서 IllegalArgumentException 발생하도록 설정 + willThrow(new IllegalArgumentException("No enum constant")) + .given(stackService) + .create(any(StackDto.Create.class)); + + // When & Then + mockMvc.perform( + post("/api/v3/stacks") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()); // 500 Internal Server Error + } + + @Test + @DisplayName("실패: null 값으로 스택 생성 시 실패한다.") + @WithMockUser(roles = "ADMIN") + void createStackWithNullValue() throws Exception { + // Given + StackDto.Create request = new StackDto.Create(null, null); + + // Service에서 예외 발생하도록 설정 + willThrow(new IllegalArgumentException("Name cannot be null")) + .given(stackService) + .create(any(StackDto.Create.class)); + + // When & Then + mockMvc.perform( + post("/api/v3/stacks") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/stack/entity/StackTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/stack/entity/StackTest.java new file mode 100644 index 00000000..6393a7bd --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/stack/entity/StackTest.java @@ -0,0 +1,47 @@ +package backend.techeerzip.domain.stack.entity; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class StackTest { + + @Test + @DisplayName("성공: Stack 엔티티가 정상적으로 생성된다.") + void createStack() { + // Given & When + Stack stack = Stack.builder().name("Spring Boot").category(StackCategory.BACKEND).build(); + + // Then + assertThat(stack.getName()).isEqualTo("Spring Boot"); + assertThat(stack.getCategory()).isEqualTo(StackCategory.BACKEND); + assertThat(stack.isDeleted()).isFalse(); + } + + @Test + @DisplayName("성공: Stack 정보를 수정할 수 있다.") + void updateStack() { + // Given + Stack stack = Stack.builder().name("React").category(StackCategory.FRONTEND).build(); + + // When + stack.update("React", StackCategory.FRONTEND); + + // Then + assertThat(stack.getName()).isEqualTo("React"); + } + + @Test + @DisplayName("성공: Stack을 삭제 처리(soft delete) 할 수 있다.") + void deleteStack() { + // Given + Stack stack = Stack.builder().name("Java").category(StackCategory.BACKEND).build(); + + // When + stack.delete(); + + // Then + assertThat(stack.isDeleted()).isTrue(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/stack/repository/StackRepositoryTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/stack/repository/StackRepositoryTest.java new file mode 100644 index 00000000..dfb31418 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/stack/repository/StackRepositoryTest.java @@ -0,0 +1,136 @@ +package backend.techeerzip.domain.stack.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import backend.techeerzip.global.config.QueryDslConfig; +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.stack.entity.Stack; +import backend.techeerzip.domain.stack.entity.StackCategory; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QueryDslConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class StackRepositoryTest extends RepositoryTestSupport { + + @Autowired private StackRepository stackRepository; + @Autowired private EntityManager entityManager; + + @BeforeEach + void setUp() { + stackRepository.deleteAll(); + clearPersistenceContext(); + } + + @Nested + @DisplayName("스택 조회 (findAllByIsDeletedFalse)") + class FindAllByIsDeletedFalseTest { + + @Test + @DisplayName("성공: 삭제되지 않은 Stack만 조회한다") + @Transactional + void findAllByIsDeletedFalse_excludesDeleted() { + // Given + Stack active = + Stack.builder() + .name("ActiveStack") + .category(StackCategory.BACKEND) + .build(); + + Stack deleted = + Stack.builder() + .name("DeletedStack") + .category(StackCategory.DEVOPS) + .build(); + deleted.delete(); // 소프트 삭제 + + stackRepository.saveAll(List.of(active, deleted)); + clearPersistenceContext(); + + // When + List result = stackRepository.findAllByIsDeletedFalse(); + + // Then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getName()).isEqualTo("ActiveStack"); + assertThat(result.getFirst().isDeleted()).isFalse(); + } + + @Test + @DisplayName("성공: 모두 삭제된 경우 빈 리스트를 반환한다") + @Transactional + void findAllByIsDeletedFalse_returnsEmpty_whenAllDeleted() { + // Given + Stack deleted1 = + Stack.builder() + .name("Deleted1") + .category(StackCategory.BACKEND) + .build(); + deleted1.delete(); + + Stack deleted2 = + Stack.builder() + .name("Deleted2") + .category(StackCategory.DEVOPS) + .build(); + deleted2.delete(); + + stackRepository.saveAll(List.of(deleted1, deleted2)); + clearPersistenceContext(); + + // When + List result = stackRepository.findAllByIsDeletedFalse(); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("성공: 삭제되지 않은 Stack이 여러 개면 모두 조회된다") + @Transactional + void findAllByIsDeletedFalse_returnsAllActive() { + // Given + Stack s1 = + Stack.builder() + .name("S1") + .category(StackCategory.BACKEND) + .build(); + Stack s2 = + Stack.builder() + .name("S2") + .category(StackCategory.FRONTEND) + .build(); + + stackRepository.saveAll(List.of(s1, s2)); + clearPersistenceContext(); + + // When + List result = stackRepository.findAllByIsDeletedFalse(); + + // Then + assertThat(result).hasSize(2); + assertThat(result).allMatch(stack -> !stack.isDeleted()); + assertThat(result).extracting(Stack::getName).containsExactlyInAnyOrder("S1", "S2"); + } + } + + private void clearPersistenceContext() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/stack/service/StackServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/stack/service/StackServiceTest.java new file mode 100644 index 00000000..e2794992 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/stack/service/StackServiceTest.java @@ -0,0 +1,109 @@ +package backend.techeerzip.domain.stack.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.context.ApplicationEventPublisher; + +import backend.techeerzip.domain.stack.dto.StackDto; +import backend.techeerzip.domain.stack.entity.Stack; +import backend.techeerzip.domain.stack.entity.StackCategory; +import backend.techeerzip.domain.stack.repository.StackRepository; +import backend.techeerzip.infra.index.IndexEvent; + +@ExtendWith(MockitoExtension.class) +public class StackServiceTest { + + @Mock private StackRepository stackRepository; + + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks private StackService stackService; + + @Test + @DisplayName("성공: 스택을 생성하고 인덱스 이벤트를 발행한다.") + void createStack() { + // Given + StackDto.Create request = new StackDto.Create("Spring", "BACKEND"); + + Stack savedStack = Stack.builder().name("Spring").category(StackCategory.BACKEND).build(); + when(stackRepository.save(any(Stack.class))).thenReturn(savedStack); + + // When + stackService.create(request); + + // Then + // 1. Repository의 save가 1번 호출되었는지 검증 + verify(stackRepository, times(1)).save(any(Stack.class)); + // 2. 이벤트 발행이 1번 호출되었는지 검증 + verify(eventPublisher, times(1)).publishEvent(any(IndexEvent.Create.class)); + } + + @Test + @DisplayName("성공: 모든 스택을 이름순으로 정렬하여 조회한다.") + void getAllStacks() { + // Given + Stack stack1 = Stack.builder().name("Zebra").category(StackCategory.OTHER).build(); + + Stack stack2 = Stack.builder().name("Alpha").category(StackCategory.BACKEND).build(); + + // Repository는 순서 상관없이 리턴한다고 가정 + when(stackRepository.findAllByIsDeletedFalse()).thenReturn(List.of(stack1, stack2)); + + // When + List result = stackService.getAll(); + + // Then + assertThat(result).hasSize(2); + // 이름순 정렬 확인 (Alpha -> Zebra) + assertThat(result.get(0).getName()).isEqualTo("Alpha"); + assertThat(result.get(1).getName()).isEqualTo("Zebra"); + } + + @Test + @DisplayName("실패: 잘못된 카테고리 값으로 스택 생성 시 IllegalArgumentException이 발생한다.") + void createStackWithInvalidCategory() { + // Given + StackDto.Create request = new StackDto.Create("Java", "INVALID_CATEGORY"); + + // When & Then + // StackCategory.valueOf()가 잘못된 값을 받으면 IllegalArgumentException 발생 + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, () -> stackService.create(request)); + } + + @Test + @DisplayName("실패: null 카테고리 값으로 스택 생성 시 예외가 발생한다.") + void createStackWithNullCategory() { + // Given + StackDto.Create request = new StackDto.Create("Java", null); + + // When & Then + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, () -> stackService.create(request)); + } + + @Test + @DisplayName("실패: Repository 예외 발생 시 스택 생성이 실패한다.") + void createStackWhenRepositoryThrowsException() { + // Given + StackDto.Create request = new StackDto.Create("Java", "BACKEND"); + + // Repository에서 예외 발생하도록 설정 + when(stackRepository.save(any(Stack.class))) + .thenThrow(new RuntimeException("Database connection failed")); + + // When & Then + org.junit.jupiter.api.Assertions.assertThrows( + RuntimeException.class, () -> stackService.create(request)); + } +}