diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/feedback/service/FeedbackServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/feedback/service/FeedbackServiceTest.java new file mode 100644 index 00000000..094a5b38 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/feedback/service/FeedbackServiceTest.java @@ -0,0 +1,501 @@ +package backend.techeerzip.domain.feedback.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import backend.techeerzip.domain.auth.jwt.CustomUserPrincipal; +import backend.techeerzip.domain.feedback.entity.Feedback; +import backend.techeerzip.domain.feedback.entity.FeedbackStatus; +import backend.techeerzip.domain.feedback.entity.FeedbackType; +import backend.techeerzip.domain.feedback.exception.FeedbackInvalidRecipientException; +import backend.techeerzip.domain.feedback.exception.FeedbackInvalidTypeException; +import backend.techeerzip.domain.feedback.exception.FeedbackNotFoundException; +import backend.techeerzip.domain.feedback.exception.FeedbackSelfRequestException; +import backend.techeerzip.domain.feedback.repository.FeedbackRepository; +import backend.techeerzip.domain.role.entity.Role; +import backend.techeerzip.domain.role.entity.RoleType; +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.exception.UserNotFoundException; +import backend.techeerzip.domain.user.repository.UserRepository; +import backend.techeerzip.global.logger.CustomLogger; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FeedbackServiceTest { + + @Mock private FeedbackRepository feedbackRepository; + @Mock private UserRepository userRepository; + @Mock private CustomLogger logger; + + @InjectMocks private FeedbackService feedbackService; + + private User mockRequester; + private User mockMentor; + private User mockNonMentor; + private Feedback mockFeedback; + private CustomUserPrincipal mockUserPrincipal; + private CustomUserPrincipal mockMentorPrincipal; + private Role mentorRole; + private Role techeerRole; + + @BeforeEach + void setUp() { + mentorRole = mock(Role.class); + when(mentorRole.getId()).thenReturn(2L); + when(mentorRole.getName()).thenReturn(RoleType.MENTOR.name()); + + techeerRole = mock(Role.class); + when(techeerRole.getId()).thenReturn(3L); + when(techeerRole.getName()).thenReturn(RoleType.TECHEER.name()); + + mockRequester = mock(User.class); + when(mockRequester.getId()).thenReturn(1L); + when(mockRequester.getRole()).thenReturn(techeerRole); + + mockMentor = mock(User.class); + when(mockMentor.getId()).thenReturn(2L); + when(mockMentor.getRole()).thenReturn(mentorRole); + + mockNonMentor = mock(User.class); + when(mockNonMentor.getId()).thenReturn(3L); + when(mockNonMentor.getRole()).thenReturn(techeerRole); + + mockFeedback = mock(Feedback.class); + when(mockFeedback.getId()).thenReturn(1L); + when(mockFeedback.getRequester()).thenReturn(mockRequester); + when(mockFeedback.getRecipient()).thenReturn(mockMentor); + when(mockFeedback.getStatus()).thenReturn(FeedbackStatus.PENDING.name()); + when(mockFeedback.isDeleted()).thenReturn(false); + when(mockFeedback.getFirstPreferredTime()).thenReturn(LocalDateTime.now().plusDays(1)); + + mockUserPrincipal = mock(CustomUserPrincipal.class); + when(mockUserPrincipal.getUserId()).thenReturn(1L); + when(mockUserPrincipal.getRole()).thenReturn(RoleType.TECHEER); + when(mockUserPrincipal.isAdmin()).thenReturn(false); + + mockMentorPrincipal = mock(CustomUserPrincipal.class); + when(mockMentorPrincipal.getUserId()).thenReturn(2L); + when(mockMentorPrincipal.getRole()).thenReturn(RoleType.MENTOR); + when(mockMentorPrincipal.isAdmin()).thenReturn(false); + } + + @Nested + @DisplayName("createFeedbackRequest 테스트") + class CreateFeedbackRequestTest { + + @Test + @DisplayName("성공적으로 피드백 요청을 생성한다") + void createFeedbackRequestSuccess() { + LocalDateTime firstTime = LocalDateTime.now().plusDays(1); + LocalDateTime secondTime = LocalDateTime.now().plusDays(2); + LocalDateTime thirdTime = LocalDateTime.now().plusDays(3); + + when(userRepository.findByIdAndIsDeletedFalse(1L)) + .thenReturn(Optional.of(mockRequester)); + when(userRepository.findByIdAndIsDeletedFalse(2L)).thenReturn(Optional.of(mockMentor)); + when(feedbackRepository.save(any(Feedback.class))).thenReturn(mockFeedback); + + Long result = + feedbackService.createFeedbackRequest( + 1L, + 2L, + FeedbackType.RESUME.name(), + "피드백 받고 싶습니다", + firstTime, + secondTime, + thirdTime, + mockUserPrincipal); + + assertThat(result).isEqualTo(1L); + verify(feedbackRepository).save(any(Feedback.class)); + } + + @Test + @DisplayName("요청자가 존재하지 않으면 예외를 발생시킨다") + void createFeedbackRequestRequesterNotFound() { + when(userRepository.findByIdAndIsDeletedFalse(1L)).thenReturn(Optional.empty()); + + assertThrows( + UserNotFoundException.class, + () -> + feedbackService.createFeedbackRequest( + 1L, + 2L, + FeedbackType.RESUME.name(), + "피드백 받고 싶습니다", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3), + mockUserPrincipal)); + } + + @Test + @DisplayName("수신자가 멘토가 아니면 예외를 발생시킨다") + void createFeedbackRequestInvalidRecipient() { + when(userRepository.findByIdAndIsDeletedFalse(1L)) + .thenReturn(Optional.of(mockRequester)); + when(userRepository.findByIdAndIsDeletedFalse(2L)) + .thenReturn(Optional.of(mockNonMentor)); + + assertThrows( + FeedbackInvalidRecipientException.class, + () -> + feedbackService.createFeedbackRequest( + 1L, + 2L, + FeedbackType.RESUME.name(), + "피드백 받고 싶습니다", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3), + mockUserPrincipal)); + } + + @Test + @DisplayName("자기 자신에게 피드백 요청하면 예외를 발생시킨다") + void createFeedbackRequestSelfRequest() { + User selfUser = mock(User.class); + when(selfUser.getId()).thenReturn(1L); + when(selfUser.getRole()).thenReturn(mentorRole); // 멘토 역할로 설정해서 수신자 검증 통과 + + when(userRepository.findByIdAndIsDeletedFalse(1L)) + .thenReturn(Optional.of(selfUser)); + + assertThrows( + FeedbackSelfRequestException.class, + () -> + feedbackService.createFeedbackRequest( + 1L, + 1L, + FeedbackType.RESUME.name(), + "피드백 받고 싶습니다", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3), + mockUserPrincipal)); + } + + @Test + @DisplayName("잘못된 피드백 타입이면 예외를 발생시킨다") + void createFeedbackRequestInvalidType() { + when(userRepository.findByIdAndIsDeletedFalse(1L)) + .thenReturn(Optional.of(mockRequester)); + when(userRepository.findByIdAndIsDeletedFalse(2L)).thenReturn(Optional.of(mockMentor)); + + assertThrows( + FeedbackInvalidTypeException.class, + () -> + feedbackService.createFeedbackRequest( + 1L, + 2L, + "INVALID_TYPE", + "피드백 받고 싶습니다", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3), + mockUserPrincipal)); + } + } + + @Nested + @DisplayName("approveFeedback 테스트") + class ApproveFeedbackTest { + + @Test + @DisplayName("성공적으로 피드백을 승인한다") + void approveFeedbackSuccess() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.of(mockFeedback)); + + assertDoesNotThrow( + () -> feedbackService.approveFeedback(1L, 2L, 1, mockMentorPrincipal)); + + verify(mockFeedback).approve(any(LocalDateTime.class)); + } + + @Test + @DisplayName("피드백이 존재하지 않으면 예외를 발생시킨다") + void approveFeedbackNotFound() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThrows( + FeedbackNotFoundException.class, + () -> feedbackService.approveFeedback(1L, 2L, 1, mockMentorPrincipal)); + } + + @Test + @DisplayName("멘토가 아니면 예외를 발생시킨다") + void approveFeedbackNotMentor() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.of(mockFeedback)); + + assertThrows( + FeedbackInvalidRecipientException.class, + () -> feedbackService.approveFeedback(1L, 2L, 1, mockUserPrincipal)); + } + + @Test + @DisplayName("잘못된 시간 선택이면 예외를 발생시킨다") + void approveFeedbackInvalidTimeChoice() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.of(mockFeedback)); + + assertThrows( + IllegalArgumentException.class, + () -> feedbackService.approveFeedback(1L, 2L, 4, mockMentorPrincipal)); + } + } + + @Nested + @DisplayName("rejectFeedback 테스트") + class RejectFeedbackTest { + + @Test + @DisplayName("성공적으로 피드백을 거절한다") + void rejectFeedbackSuccess() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.of(mockFeedback)); + + assertDoesNotThrow( + () -> + feedbackService.rejectFeedback( + 1L, 2L, "시간이 맞지 않습니다", mockMentorPrincipal)); + + verify(mockFeedback).reject("시간이 맞지 않습니다"); + } + + @Test + @DisplayName("피드백이 존재하지 않으면 예외를 발생시킨다") + void rejectFeedbackNotFound() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThrows( + FeedbackNotFoundException.class, + () -> + feedbackService.rejectFeedback( + 1L, 2L, "시간이 맞지 않습니다", mockMentorPrincipal)); + } + } + + @Nested + @DisplayName("getMyFeedbackRequests 테스트") + class GetMyFeedbackRequestsTest { + + @Test + @DisplayName("성공적으로 나의 피드백 요청 목록을 조회한다") + void getMyFeedbackRequestsSuccess() { + List feedbackList = List.of(mockFeedback); + when(feedbackRepository.findByRequesterIdAndIsDeletedFalse(1L)) + .thenReturn(feedbackList); + + List result = feedbackService.getMyFeedbackRequests(1L); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(mockFeedback); + } + } + + @Nested + @DisplayName("getReceivedFeedbackRequests 테스트") + class GetReceivedFeedbackRequestsTest { + + @Test + @DisplayName("성공적으로 받은 피드백 요청 목록을 조회한다") + void getReceivedFeedbackRequestsSuccess() { + List feedbackList = List.of(mockFeedback); + when(userRepository.findByIdAndIsDeletedFalse(2L)) + .thenReturn(Optional.of(mockMentor)); + when(feedbackRepository.findByRecipientIdAndIsDeletedFalse(2L)) + .thenReturn(feedbackList); + + List result = feedbackService.getReceivedFeedbackRequests(2L); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(mockFeedback); + } + } + + @Nested + @DisplayName("updateFeedback 테스트") + class UpdateFeedbackTest { + + @Test + @DisplayName("성공적으로 피드백을 수정한다") + void updateFeedbackSuccess() { + LocalDateTime firstTime = LocalDateTime.now().plusDays(1); + LocalDateTime secondTime = LocalDateTime.now().plusDays(2); + LocalDateTime thirdTime = LocalDateTime.now().plusDays(3); + + when(feedbackRepository.findById(1L)).thenReturn(Optional.of(mockFeedback)); + + assertDoesNotThrow( + () -> + feedbackService.updateFeedback( + 1L, + 1L, + FeedbackType.RESUME.name(), + "수정된 신청 사유", + firstTime, + secondTime, + thirdTime, + mockUserPrincipal)); + + verify(mockFeedback) + .update( + FeedbackType.RESUME.name(), + FeedbackStatus.PENDING.name(), + "수정된 신청 사유", + firstTime, + secondTime, + thirdTime); + } + + @Test + @DisplayName("피드백이 존재하지 않으면 예외를 발생시킨다") + void updateFeedbackNotFound() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThrows( + FeedbackNotFoundException.class, + () -> + feedbackService.updateFeedback( + 1L, + 1L, + FeedbackType.RESUME.name(), + "수정된 신청 사유", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3), + mockUserPrincipal)); + } + } + + @Nested + @DisplayName("deleteFeedback 테스트") + class DeleteFeedbackTest { + + @Test + @DisplayName("성공적으로 피드백을 삭제한다") + void deleteFeedbackSuccess() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.of(mockFeedback)); + + assertDoesNotThrow(() -> feedbackService.deleteFeedback(1L, 1L, mockUserPrincipal)); + + verify(mockFeedback).delete(); + } + + @Test + @DisplayName("피드백이 존재하지 않으면 예외를 발생시킨다") + void deleteFeedbackNotFound() { + when(feedbackRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThrows( + FeedbackNotFoundException.class, + () -> feedbackService.deleteFeedback(1L, 1L, mockUserPrincipal)); + } + } + + @Nested + @DisplayName("getFeedbackById 테스트") + class GetFeedbackByIdTest { + + @Test + @DisplayName("성공적으로 피드백을 조회한다") + void getFeedbackByIdSuccess() { + when(feedbackRepository.findByIdWithUsers(1L)).thenReturn(Optional.of(mockFeedback)); + + Feedback result = feedbackService.getFeedbackById(1L, 1L, mockUserPrincipal); + + assertThat(result).isEqualTo(mockFeedback); + } + + @Test + @DisplayName("피드백이 존재하지 않으면 예외를 발생시킨다") + void getFeedbackByIdNotFound() { + when(feedbackRepository.findByIdWithUsers(1L)).thenReturn(Optional.empty()); + + assertThrows( + FeedbackNotFoundException.class, + () -> feedbackService.getFeedbackById(1L, 1L, mockUserPrincipal)); + } + } + + @Nested + @DisplayName("getMentorFeedbackGuidelines 테스트") + class GetMentorFeedbackGuidelinesTest { + + @Test + @DisplayName("성공적으로 멘토 가이드라인을 조회한다") + void getMentorFeedbackGuidelinesSuccess() { + List mentors = List.of(mockMentor); + when(userRepository.findByRoleIdLessThanEqualAndIsDeletedFalse(2L)) + .thenReturn(mentors); + + List result = feedbackService.getMentorFeedbackGuidelines(); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(mockMentor); + } + } + + @Nested + @DisplayName("updateMentorFeedbackGuidelines 테스트") + class UpdateMentorFeedbackGuidelinesTest { + + @Test + @DisplayName("성공적으로 멘토 가이드라인을 수정한다") + void updateMentorFeedbackGuidelinesSuccess() { + when(userRepository.findByIdAndIsDeletedFalse(2L)) + .thenReturn(Optional.of(mockMentor)); + + assertDoesNotThrow( + () -> + feedbackService.updateMentorFeedbackGuidelines( + 2L, "새로운 가이드라인", mockMentorPrincipal)); + + verify(mockMentor).updateFeedbackNotes("새로운 가이드라인"); + } + + @Test + @DisplayName("멘토가 존재하지 않으면 예외를 발생시킨다") + void updateMentorFeedbackGuidelinesNotFound() { + when(userRepository.findByIdAndIsDeletedFalse(2L)).thenReturn(Optional.empty()); + + assertThrows( + UserNotFoundException.class, + () -> + feedbackService.updateMentorFeedbackGuidelines( + 2L, "새로운 가이드라인", mockMentorPrincipal)); + } + + @Test + @DisplayName("멘토가 아니면 예외를 발생시킨다") + void updateMentorFeedbackGuidelinesNotMentor() { + when(userRepository.findByIdAndIsDeletedFalse(2L)) + .thenReturn(Optional.of(mockMentor)); + + assertThrows( + FeedbackInvalidRecipientException.class, + () -> + feedbackService.updateMentorFeedbackGuidelines( + 2L, "새로운 가이드라인", mockUserPrincipal)); + } + } +} \ No newline at end of file diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/task/service/TaskServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/task/service/TaskServiceTest.java new file mode 100644 index 00000000..9a933f68 --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/task/service/TaskServiceTest.java @@ -0,0 +1,356 @@ +package backend.techeerzip.domain.task.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import backend.techeerzip.domain.task.entity.TaskType; +import backend.techeerzip.global.logger.CustomLogger; +import backend.techeerzip.infra.rabbitmq.QueueType; +import backend.techeerzip.infra.rabbitmq.service.RabbitMqService; +import backend.techeerzip.infra.redis.service.RedisService; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TaskServiceTest { + + @Mock private RabbitMqService rabbitMqService; + @Mock private RedisService redisService; + @Mock private CustomLogger logger; + + @InjectMocks private TaskService taskService; + + private Long userId; + private String blogUrl; + private String resumeUrl; + private Long resumeId; + private Long answerId; + private String csData; + + @BeforeEach + void setUp() { + userId = 1L; + blogUrl = "https://blog.example.com"; + resumeUrl = "https://example.com/resume.pdf"; + resumeId = 100L; + answerId = 200L; + csData = "CS 문제 답변 데이터"; + } + + @Nested + @DisplayName("requestBlogCrawlingTask 테스트") + class RequestBlogCrawlingTaskTest { + + @Test + @DisplayName("성공적으로 단일 블로그 크롤링 태스크를 요청한다") + void requestBlogCrawlingTaskSuccess() { + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + assertDoesNotThrow( + () -> + taskService.requestBlogCrawlingTask( + TaskType.SIGNUP_BLOG_FETCH, userId, blogUrl)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.CRAWL_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.SIGNUP_BLOG_FETCH.getValue())); + verify(redisService).setTaskStatus(anyString(), eq(blogUrl)); + verify(redisService).getTaskDetails(anyString()); + } + + @Test + @DisplayName("DAILY_UPDATE 태스크 타입으로 블로그 크롤링을 요청한다") + void requestBlogCrawlingTaskWithDailyUpdate() { + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + assertDoesNotThrow( + () -> + taskService.requestBlogCrawlingTask( + TaskType.DAILY_UPDATE, userId, blogUrl)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.CRAWL_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.DAILY_UPDATE.getValue())); + verify(redisService).setTaskStatus(anyString(), eq(blogUrl)); + } + } + + @Nested + @DisplayName("requestBlogCrawlingTasks 테스트") + class RequestBlogCrawlingTasksTest { + + @Test + @DisplayName("성공적으로 다중 블로그 크롤링 태스크를 요청한다") + void requestBlogCrawlingTasksSuccess() { + List blogUrls = + Arrays.asList( + "https://blog1.example.com", + "https://blog2.example.com", + "https://blog3.example.com"); + + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + assertDoesNotThrow( + () -> + taskService.requestBlogCrawlingTasks( + TaskType.SIGNUP_BLOG_FETCH, userId, blogUrls)); + + verify(rabbitMqService, times(3)) + .sendToQueue( + eq(QueueType.CRAWL_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.SIGNUP_BLOG_FETCH.getValue())); + verify(redisService, times(3)).setTaskStatus(anyString(), anyString()); + verify(redisService, times(3)).getTaskDetails(anyString()); + } + + @Test + @DisplayName("빈 블로그 URL 리스트로 요청한다") + void requestBlogCrawlingTasksWithEmptyList() { + List emptyBlogUrls = Arrays.asList(); + + assertDoesNotThrow( + () -> + taskService.requestBlogCrawlingTasks( + TaskType.SIGNUP_BLOG_FETCH, userId, emptyBlogUrls)); + + verify(rabbitMqService, times(0)).sendToQueue(any(), any(), any(), any()); + verify(redisService, times(0)).setTaskStatus(any(), any()); + } + + @Test + @DisplayName("단일 블로그 URL로 요청한다") + void requestBlogCrawlingTasksWithSingleUrl() { + List singleBlogUrl = Arrays.asList(blogUrl); + + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + assertDoesNotThrow( + () -> + taskService.requestBlogCrawlingTasks( + TaskType.SIGNUP_BLOG_FETCH, userId, singleBlogUrl)); + + verify(rabbitMqService, times(1)) + .sendToQueue( + eq(QueueType.CRAWL_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.SIGNUP_BLOG_FETCH.getValue())); + verify(redisService, times(1)).setTaskStatus(anyString(), eq(blogUrl)); + } + } + + @Nested + @DisplayName("requestResumeExtraction 테스트") + class RequestResumeExtractionTest { + + @Test + @DisplayName("성공적으로 이력서 추출 태스크를 요청한다") + void requestResumeExtractionSuccess() { + assertDoesNotThrow( + () -> + taskService.requestResumeExtraction( + TaskType.RESUME_EXTRACTION, userId, resumeId, resumeUrl)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.RESUME_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.RESUME_EXTRACTION.getValue())); + verify(redisService).setTaskStatusWithUserId(anyString(), eq(userId)); + } + + @Test + @DisplayName("다른 사용자 ID로 이력서 추출 태스크를 요청한다") + void requestResumeExtractionWithDifferentUserId() { + Long differentUserId = 999L; + + assertDoesNotThrow( + () -> + taskService.requestResumeExtraction( + TaskType.RESUME_EXTRACTION, + differentUserId, + resumeId, + resumeUrl)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.RESUME_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.RESUME_EXTRACTION.getValue())); + verify(redisService).setTaskStatusWithUserId(anyString(), eq(differentUserId)); + } + } + + @Nested + @DisplayName("requestCsGrading 테스트") + class RequestCsGradingTest { + + @Test + @DisplayName("성공적으로 CS 채점 태스크를 요청한다") + void requestCsGradingSuccess() { + assertDoesNotThrow( + () -> taskService.requestCsGrading(userId, answerId, csData)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.RESUME_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.CS_GRADING.getValue())); + verify(redisService).setTaskStatusWithUserId(anyString(), eq(userId)); + } + + @Test + @DisplayName("다른 답변 ID로 CS 채점 태스크를 요청한다") + void requestCsGradingWithDifferentAnswerId() { + Long differentAnswerId = 9999L; + + assertDoesNotThrow( + () -> taskService.requestCsGrading(userId, differentAnswerId, csData)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.RESUME_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.CS_GRADING.getValue())); + verify(redisService).setTaskStatusWithUserId(anyString(), eq(userId)); + } + + @Test + @DisplayName("빈 데이터로 CS 채점 태스크를 요청한다") + void requestCsGradingWithEmptyData() { + String emptyData = ""; + + assertDoesNotThrow(() -> taskService.requestCsGrading(userId, answerId, emptyData)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.RESUME_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.CS_GRADING.getValue())); + verify(redisService).setTaskStatusWithUserId(anyString(), eq(userId)); + } + } + + @Nested + @DisplayName("Queue 데이터 검증 테스트") + class QueueDataValidationTest { + + @Test + @DisplayName("Go MQ 데이터 형식이 올바른지 검증한다") + void validateGoMQDataFormat() { + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + taskService.requestBlogCrawlingTask(TaskType.SIGNUP_BLOG_FETCH, userId, blogUrl); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.CRAWL_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.SIGNUP_BLOG_FETCH.getValue())); + } + + @Test + @DisplayName("Python MQ 데이터 형식이 올바른지 검증한다") + void validatePythonMQDataFormat() { + taskService.requestResumeExtraction( + TaskType.RESUME_EXTRACTION, userId, resumeId, resumeUrl); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.RESUME_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.RESUME_EXTRACTION.getValue())); + } + } + + @Nested + @DisplayName("TaskType 별 동작 테스트") + class TaskTypeSpecificTest { + + @Test + @DisplayName("SHARED_POST_FETCH 태스크 타입으로 블로그 크롤링을 요청한다") + void requestBlogCrawlingWithSharedPostFetch() { + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + assertDoesNotThrow( + () -> + taskService.requestBlogCrawlingTask( + TaskType.SHARED_POST_FETCH, userId, blogUrl)); + + verify(rabbitMqService) + .sendToQueue( + eq(QueueType.CRAWL_QUEUE), + any(Map.class), + anyString(), + eq(TaskType.SHARED_POST_FETCH.getValue())); + } + + @Test + @DisplayName("각 TaskType이 올바른 큐로 전송되는지 확인한다") + void verifyTaskTypeQueueMapping() { + Map mockTaskDetails = new HashMap<>(); + mockTaskDetails.put("status", "pending"); + when(redisService.getTaskDetails(anyString())).thenReturn(mockTaskDetails); + + taskService.requestBlogCrawlingTask(TaskType.SIGNUP_BLOG_FETCH, userId, blogUrl); + verify(rabbitMqService) + .sendToQueue(eq(QueueType.CRAWL_QUEUE), any(), any(), any()); + + taskService.requestResumeExtraction( + TaskType.RESUME_EXTRACTION, userId, resumeId, resumeUrl); + verify(rabbitMqService) + .sendToQueue(eq(QueueType.RESUME_QUEUE), any(), any(), any()); + + taskService.requestCsGrading(userId, answerId, csData); + verify(rabbitMqService, times(2)) + .sendToQueue(eq(QueueType.RESUME_QUEUE), any(), any(), any()); + } + } +} \ No newline at end of file diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceServiceTest.java new file mode 100644 index 00000000..2225cdce --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/zoom/service/ZoomAttendanceServiceTest.java @@ -0,0 +1,325 @@ +package backend.techeerzip.domain.zoom.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +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; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ZoomAttendanceService 테스트") +class ZoomAttendanceServiceTest { + + @Mock + private ZoomApiClient zoomApiClient; + + @Mock + private ZoomAttendanceRepository zoomAttendanceRepository; + + @InjectMocks + private ZoomAttendanceService zoomAttendanceService; + + @Nested + @DisplayName("API 연결 테스트") + class TestConnectionTest { + + @Test + @DisplayName("API 연결이 성공하면 true를 반환한다") + void testConnection_Success() { + // given + when(zoomApiClient.testConnection()).thenReturn(true); + + // when + boolean result = zoomAttendanceService.testConnection(); + + // then + assertTrue(result); + verify(zoomApiClient).testConnection(); + } + + @Test + @DisplayName("API 연결이 실패하면 false를 반환한다") + void testConnection_Failure() { + // given + when(zoomApiClient.testConnection()).thenReturn(false); + + // when + boolean result = zoomAttendanceService.testConnection(); + + // then + assertFalse(result); + verify(zoomApiClient).testConnection(); + } + } + + @Nested + @DisplayName("웹훅 이벤트 처리") + class HandleWebhookEventTest { + + @Test + @DisplayName("참가자 입장 이벤트를 올바르게 처리한다") + void handleWebhookEvent_ParticipantJoined() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_joined", "2025-01-20T13:30:15Z", null, "test-uuid", "testUser"); + when(zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class))) + .thenReturn(Optional.empty()); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository).save(any(ZoomAttendance.class)); + } + + @Test + @DisplayName("참가자 퇴장 이벤트를 올바르게 처리한다") + void handleWebhookEvent_ParticipantLeft() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_left", null, "2025-01-20T15:30:15Z", "test-uuid", "testUser"); + ZoomAttendance existingAttendance = ZoomAttendance.builder() + .participantUuid("test-uuid") + .userName("testUser") + .joinTime(LocalDateTime.of(2025, 1, 20, 13, 30, 15)) + .meetingDate(LocalDate.of(2025, 1, 20)) + .build(); + + when(zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class))) + .thenReturn(Optional.of(existingAttendance)); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository).save(existingAttendance); + assertNotNull(existingAttendance.getLeaveTime()); + assertNotNull(existingAttendance.getDurationMinutes()); + } + + @Test + @DisplayName("알 수 없는 이벤트 타입은 무시한다") + void handleWebhookEvent_UnknownEventType() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("unknown.event", "2025-01-20T13:30:15Z", null, "test-uuid", "testUser"); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("참가자 입장 이벤트 처리") + class HandleParticipantJoinedTest { + + @Test + @DisplayName("새로운 참가자 입장 시 출석 기록을 생성한다") + void handleParticipantJoined_NewParticipant() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_joined", "2025-01-20T13:30:15Z", null, "test-uuid", "testUser"); + when(zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class))) + .thenReturn(Optional.empty()); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository).save(argThat(attendance -> + "test-uuid".equals(attendance.getParticipantUuid()) && + "testUser".equals(attendance.getUserName()) && + attendance.getJoinTime() != null && + attendance.getLeaveTime() == null && + attendance.getDurationMinutes() == null + )); + } + + @Test + @DisplayName("기존 출석 기록이 있는 경우 무시한다") + void handleParticipantJoined_ExistingRecord() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_joined", "2025-01-20T13:30:15Z", null, "test-uuid", "testUser"); + ZoomAttendance existingAttendance = ZoomAttendance.builder() + .participantUuid("test-uuid") + .userName("testUser") + .joinTime(LocalDateTime.of(2025, 1, 20, 13, 30, 15)) + .meetingDate(LocalDate.of(2025, 1, 20)) + .build(); + + when(zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class))) + .thenReturn(Optional.of(existingAttendance)); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + + @Test + @DisplayName("참가자 정보가 null인 경우 처리하지 않는다") + void handleParticipantJoined_NullParticipant() { + // given + ZoomWebhookEvent event = createMockWebhookEventWithNullParticipant("meeting.participant_joined"); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + + @Test + @DisplayName("필수 정보가 누락된 경우 처리하지 않는다") + void handleParticipantJoined_MissingRequiredInfo() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_joined", "2025-01-20T13:30:15Z", null, null, "testUser"); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("참가자 퇴장 이벤트 처리") + class HandleParticipantLeftTest { + + @Test + @DisplayName("기존 출석 기록의 퇴장 시간을 업데이트한다") + void handleParticipantLeft_UpdateExistingRecord() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_left", null, "2025-01-20T15:30:15Z", "test-uuid", "testUser"); + ZoomAttendance existingAttendance = ZoomAttendance.builder() + .participantUuid("test-uuid") + .userName("testUser") + .joinTime(LocalDateTime.of(2025, 1, 20, 13, 30, 15)) + .meetingDate(LocalDate.of(2025, 1, 20)) + .build(); + + when(zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class))) + .thenReturn(Optional.of(existingAttendance)); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository).save(existingAttendance); + assertEquals(LocalDateTime.of(2025, 1, 20, 15, 30, 15), existingAttendance.getLeaveTime()); + assertEquals(120.0f, existingAttendance.getDurationMinutes()); + } + + @Test + @DisplayName("출석 기록이 없는 경우 처리하지 않는다") + void handleParticipantLeft_NoExistingRecord() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_left", null, "2025-01-20T15:30:15Z", "test-uuid", "testUser"); + when(zoomAttendanceRepository.findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class))) + .thenReturn(Optional.empty()); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + + @Test + @DisplayName("소회의실 관련 이벤트는 무시한다") + void handleParticipantLeft_IgnoreBreakoutRoomEvents() { + // given + ZoomWebhookEvent event = createMockWebhookEventWithLeaveReason("meeting.participant_left", null, "2025-01-20T15:30:15Z", "test-uuid", "testUser", "join breakout room"); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).findByParticipantUuidAndMeetingDate(anyString(), any(LocalDate.class)); + } + + @Test + @DisplayName("참가자 정보가 null인 경우 처리하지 않는다") + void handleParticipantLeft_NullParticipant() { + // given + ZoomWebhookEvent event = createMockWebhookEventWithNullParticipant("meeting.participant_left"); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + + @Test + @DisplayName("필수 정보가 누락된 경우 처리하지 않는다") + void handleParticipantLeft_MissingRequiredInfo() { + // given + ZoomWebhookEvent event = createMockWebhookEvent("meeting.participant_left", null, null, "test-uuid", "testUser"); + + // when + zoomAttendanceService.handleWebhookEvent(event); + + // then + verify(zoomAttendanceRepository, never()).save(any()); + } + } + + // Mock 객체 생성을 위한 헬퍼 메서드들 + private ZoomWebhookEvent createMockWebhookEvent(String eventType, String joinTime, String leaveTime, String participantUuid, String userName) { + ZoomWebhookEvent event = mock(ZoomWebhookEvent.class); + ZoomWebhookEvent.WebhookPayload payload = mock(ZoomWebhookEvent.WebhookPayload.class); + ZoomWebhookEvent.WebhookObject object = mock(ZoomWebhookEvent.WebhookObject.class); + ZoomWebhookEvent.WebhookParticipant participant = mock(ZoomWebhookEvent.WebhookParticipant.class); + + when(event.getEvent()).thenReturn(eventType); + when(event.getPayload()).thenReturn(payload); + when(payload.getObject()).thenReturn(object); + when(object.getParticipant()).thenReturn(participant); + when(participant.getParticipantUuid()).thenReturn(participantUuid); + when(participant.getUserName()).thenReturn(userName); + when(participant.getJoinTime()).thenReturn(joinTime); + when(participant.getLeaveTime()).thenReturn(leaveTime); + + return event; + } + + private ZoomWebhookEvent createMockWebhookEventWithLeaveReason(String eventType, String joinTime, String leaveTime, String participantUuid, String userName, String leaveReason) { + ZoomWebhookEvent event = createMockWebhookEvent(eventType, joinTime, leaveTime, participantUuid, userName); + ZoomWebhookEvent.WebhookParticipant participant = event.getPayload().getObject().getParticipant(); + when(participant.getLeaveReason()).thenReturn(leaveReason); + return event; + } + + private ZoomWebhookEvent createMockWebhookEventWithNullParticipant(String eventType) { + ZoomWebhookEvent event = mock(ZoomWebhookEvent.class); + ZoomWebhookEvent.WebhookPayload payload = mock(ZoomWebhookEvent.WebhookPayload.class); + ZoomWebhookEvent.WebhookObject object = mock(ZoomWebhookEvent.WebhookObject.class); + + when(event.getEvent()).thenReturn(eventType); + when(event.getPayload()).thenReturn(payload); + when(payload.getObject()).thenReturn(object); + when(object.getParticipant()).thenReturn(null); + + return event; + } +} \ No newline at end of file diff --git a/techeerzip/src/test/java/backend/techeerzip/domain/zoom/service/ZoomStatisticsServiceTest.java b/techeerzip/src/test/java/backend/techeerzip/domain/zoom/service/ZoomStatisticsServiceTest.java new file mode 100644 index 00000000..2914197f --- /dev/null +++ b/techeerzip/src/test/java/backend/techeerzip/domain/zoom/service/ZoomStatisticsServiceTest.java @@ -0,0 +1,264 @@ +package backend.techeerzip.domain.zoom.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import backend.techeerzip.domain.user.entity.User; +import backend.techeerzip.domain.user.exception.UserNotFoundException; +import backend.techeerzip.domain.user.repository.UserRepository; +import backend.techeerzip.domain.zoom.dto.response.ZoomMonthlyStatsDto; +import backend.techeerzip.domain.zoom.dto.response.ZoomRankResponse; +import backend.techeerzip.domain.zoom.dto.response.ZoomStatisticsResponse; +import backend.techeerzip.domain.zoom.dto.response.ZoomTopUserDto; +import backend.techeerzip.domain.zoom.repository.ZoomAttendanceRepository; +import backend.techeerzip.domain.role.entity.Role; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ZoomStatisticsService 테스트") +class ZoomStatisticsServiceTest { + + @Mock + private ZoomAttendanceRepository zoomAttendanceRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ZoomStatisticsService zoomStatisticsService; + + @Nested + @DisplayName("사용자 Zoom 통계 조회") + class GetUserZoomStatisticsTest { + + @Test + @DisplayName("사용자가 존재할 때 최근 12개월 통계를 조회한다") + void getUserZoomStatistics_ValidUser_Success() { + // given + Long userId = 1L; + User user = createMockUser(userId, "testUser", "test@example.com"); + LocalDate fromDate = LocalDate.now().withDayOfMonth(1).minusMonths(11); + + List mockMonthlyData = Arrays.asList( + createMockZoomMonthlyStatsDto(2024, 12, 15L, 1200L, 80.0), + createMockZoomMonthlyStatsDto(2024, 11, 20L, 1800L, 90.0) + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(zoomAttendanceRepository.findRecentMonthlyStatisticsByUserName(user.getName(), fromDate)) + .thenReturn(mockMonthlyData); + + // when + List result = zoomStatisticsService.getUserZoomStatistics(userId); + + // then + assertEquals(2, result.size()); + + ZoomStatisticsResponse firstStat = result.get(0); + assertEquals(2024, firstStat.getYear()); + assertEquals(12, firstStat.getMonth()); + assertEquals(15, firstStat.getAttendanceDays()); + assertEquals(1200, firstStat.getTotalMinutes()); + assertEquals(80.0, firstStat.getAverageMinutes()); + + verify(userRepository).findById(userId); + verify(zoomAttendanceRepository).findRecentMonthlyStatisticsByUserName(user.getName(), fromDate); + } + + @Test + @DisplayName("사용자가 존재하지 않을 때 UserNotFoundException을 발생시킨다") + void getUserZoomStatistics_UserNotFound_ThrowsException() { + // given + Long userId = 999L; + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(UserNotFoundException.class, () -> { + zoomStatisticsService.getUserZoomStatistics(userId); + }); + + verify(userRepository).findById(userId); + verify(zoomAttendanceRepository, never()).findRecentMonthlyStatisticsByUserName(anyString(), any(LocalDate.class)); + } + + @Test + @DisplayName("통계 데이터가 없을 때 빈 리스트를 반환한다") + void getUserZoomStatistics_NoData_ReturnsEmptyList() { + // given + Long userId = 1L; + User user = createMockUser(userId, "testUser", "test@example.com"); + LocalDate fromDate = LocalDate.now().withDayOfMonth(1).minusMonths(11); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(zoomAttendanceRepository.findRecentMonthlyStatisticsByUserName(user.getName(), fromDate)) + .thenReturn(Collections.emptyList()); + + // when + List result = zoomStatisticsService.getUserZoomStatistics(userId); + + // then + assertTrue(result.isEmpty()); + verify(userRepository).findById(userId); + verify(zoomAttendanceRepository).findRecentMonthlyStatisticsByUserName(user.getName(), fromDate); + } + } + + @Nested + @DisplayName("월별 1등 사용자 조회") + class GetTopUserByMonthTest { + + @Test + @DisplayName("해당 월에 데이터가 있을 때 1등 사용자를 조회한다") + void getTopUserByMonth_ValidData_Success() { + // given + Integer year = 2024; + Integer month = 12; + String userName = "topUser"; + + ZoomTopUserDto mockTopUserDto = createMockZoomTopUserDto(userName, 25L, 2400L); + User user = createMockUser(1L, userName, "top@example.com"); + + when(zoomAttendanceRepository.findTopUserByTotalMinutesInMonth(year, month)) + .thenReturn(Arrays.asList(mockTopUserDto)); + when(userRepository.findByIsDeletedFalse()) + .thenReturn(Arrays.asList(user)); + + // when + ZoomRankResponse result = zoomStatisticsService.getTopUserByMonth(year, month); + + // then + assertNotNull(result); + assertNotNull(result.getUser()); + assertEquals(userName, result.getUser().getName()); + assertEquals(2400, result.getTotalMinutes().intValue()); + assertEquals(25, result.getAttendanceDays().intValue()); + + verify(zoomAttendanceRepository).findTopUserByTotalMinutesInMonth(year, month); + verify(userRepository).findByIsDeletedFalse(); + } + + @Test + @DisplayName("해당 월에 데이터가 없을 때 null을 반환한다") + void getTopUserByMonth_NoData_ReturnsNull() { + // given + Integer year = 2024; + Integer month = 12; + + when(zoomAttendanceRepository.findTopUserByTotalMinutesInMonth(year, month)) + .thenReturn(Collections.emptyList()); + + // when + ZoomRankResponse result = zoomStatisticsService.getTopUserByMonth(year, month); + + // then + assertNull(result); + verify(zoomAttendanceRepository).findTopUserByTotalMinutesInMonth(year, month); + } + + @Test + @DisplayName("1등 사용자가 등록된 사용자가 아닐 때 UserSummary는 null로 설정한다") + void getTopUserByMonth_UserNotRegistered_UserSummaryNull() { + // given + Integer year = 2024; + Integer month = 12; + String userName = "unknownUser"; + + ZoomTopUserDto mockTopUserDto = createMockZoomTopUserDto(userName, 25L, 2400L); + User otherUser = createMockUser(1L, "otherUser", "other@example.com"); + + when(zoomAttendanceRepository.findTopUserByTotalMinutesInMonth(year, month)) + .thenReturn(Arrays.asList(mockTopUserDto)); + when(userRepository.findByIsDeletedFalse()) + .thenReturn(Arrays.asList(otherUser)); + + // when + ZoomRankResponse result = zoomStatisticsService.getTopUserByMonth(year, month); + + // then + assertNotNull(result); + assertNull(result.getUser()); + assertEquals(2400, result.getTotalMinutes().intValue()); + assertEquals(25, result.getAttendanceDays().intValue()); + + verify(zoomAttendanceRepository).findTopUserByTotalMinutesInMonth(year, month); + verify(userRepository).findByIsDeletedFalse(); + } + + @Test + @DisplayName("여러 사용자가 있을 때 첫 번째 사용자(1등)를 반환한다") + void getTopUserByMonth_MultipleUsers_ReturnsFirst() { + // given + Integer year = 2024; + Integer month = 12; + String topUserName = "topUser"; + String secondUserName = "secondUser"; + + ZoomTopUserDto topUserDto = createMockZoomTopUserDto(topUserName, 25L, 2400L); + ZoomTopUserDto secondUserDto = createMockZoomTopUserDto(secondUserName, 20L, 2000L); + + User topUser = createMockUser(1L, topUserName, "top@example.com"); + + when(zoomAttendanceRepository.findTopUserByTotalMinutesInMonth(year, month)) + .thenReturn(Arrays.asList(topUserDto, secondUserDto)); + when(userRepository.findByIsDeletedFalse()) + .thenReturn(Arrays.asList(topUser)); + + // when + ZoomRankResponse result = zoomStatisticsService.getTopUserByMonth(year, month); + + // then + assertNotNull(result); + assertNotNull(result.getUser()); + assertEquals(topUserName, result.getUser().getName()); + assertEquals(2400, result.getTotalMinutes().intValue()); + assertEquals(25, result.getAttendanceDays().intValue()); + + verify(zoomAttendanceRepository).findTopUserByTotalMinutesInMonth(year, month); + verify(userRepository).findByIsDeletedFalse(); + } + } + + // Mock 객체 생성을 위한 헬퍼 메서드들 + private User createMockUser(Long id, String name, String email) { + User user = mock(User.class); + when(user.getId()).thenReturn(id); + when(user.getName()).thenReturn(name); + when(user.getEmail()).thenReturn(email); + return user; + } + + private ZoomMonthlyStatsDto createMockZoomMonthlyStatsDto(Integer year, Integer month, Long attendanceDays, Long totalMinutes, Double averageMinutes) { + ZoomMonthlyStatsDto dto = mock(ZoomMonthlyStatsDto.class); + when(dto.getYear()).thenReturn(year); + when(dto.getMonth()).thenReturn(month); + when(dto.getAttendanceDays()).thenReturn(attendanceDays); + when(dto.getTotalMinutes()).thenReturn(totalMinutes); + when(dto.getAverageMinutes()).thenReturn(averageMinutes); + return dto; + } + + private ZoomTopUserDto createMockZoomTopUserDto(String userName, Long attendanceDays, Long totalMinutes) { + ZoomTopUserDto dto = mock(ZoomTopUserDto.class); + when(dto.getUserName()).thenReturn(userName); + when(dto.getAttendanceDays()).thenReturn(attendanceDays); + when(dto.getTotalMinutes()).thenReturn(totalMinutes); + return dto; + } +} \ No newline at end of file