diff --git a/src/test/java/com/gitranker/api/batch/listener/BatchProgressListenerTest.java b/src/test/java/com/gitranker/api/batch/listener/BatchProgressListenerTest.java new file mode 100644 index 0000000..af2caa4 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/listener/BatchProgressListenerTest.java @@ -0,0 +1,88 @@ +package com.gitranker.api.batch.listener; + +import com.gitranker.api.domain.user.UserRepository; +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.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BatchProgressListenerTest { + + @InjectMocks + private BatchProgressListener listener; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("beforeChunk initializes total count once from repository") + void initializesTotalCountOnlyOnce() { + when(userRepository.count()).thenReturn(42L, 100L); + + listener.beforeChunk(chunkContext("DailyScoreRecalculationJob", 0)); + listener.beforeChunk(chunkContext("DailyScoreRecalculationJob", 0)); + + verify(userRepository, times(1)).count(); + assertThat(ReflectionTestUtils.getField(listener, "totalCount")).isEqualTo(42); + assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(0); + } + + @Test + @DisplayName("afterChunk does nothing when total count is zero") + void ignoresAfterChunkWhenNothingInitialized() { + listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 25)); + + assertThat(ReflectionTestUtils.getField(listener, "totalCount")).isEqualTo(0); + assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(0); + } + + @Test + @DisplayName("afterChunk rounds progress down to the nearest ten percent interval") + void updatesProgressAtInterval() { + ReflectionTestUtils.setField(listener, "totalCount", 100); + ReflectionTestUtils.setField(listener, "lastLoggedPercentage", 0); + + listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 27)); + assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(20); + + listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 29)); + assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(20); + + listener.afterChunk(chunkContext("DailyScoreRecalculationJob", 31)); + assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(30); + } + + @Test + @DisplayName("reset clears cached progress state") + void resetsCachedProgressState() { + ReflectionTestUtils.setField(listener, "totalCount", 100); + ReflectionTestUtils.setField(listener, "lastLoggedPercentage", 40); + + listener.reset(); + + assertThat(ReflectionTestUtils.getField(listener, "totalCount")).isEqualTo(0); + assertThat(ReflectionTestUtils.getField(listener, "lastLoggedPercentage")).isEqualTo(0); + } + + private ChunkContext chunkContext(String jobName, long writeCount) { + JobExecution jobExecution = new JobExecution(new JobInstance(1L, jobName), new JobParameters()); + StepExecution stepExecution = jobExecution.createStepExecution("scoreRecalculationStep"); + stepExecution.setWriteCount(writeCount); + return new ChunkContext(new StepContext(stepExecution)); + } +} diff --git a/src/test/java/com/gitranker/api/batch/listener/GitHubCostListenerTest.java b/src/test/java/com/gitranker/api/batch/listener/GitHubCostListenerTest.java new file mode 100644 index 0000000..26f05f2 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/listener/GitHubCostListenerTest.java @@ -0,0 +1,89 @@ +package com.gitranker.api.batch.listener; + +import com.gitranker.api.batch.metrics.BatchMetrics; +import com.gitranker.api.domain.user.UserRepository; +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.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.StepExecution; + +import java.time.LocalDateTime; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitHubCostListenerTest { + + @InjectMocks + private GitHubCostListener listener; + + @Mock + private UserRepository userRepository; + + @Mock + private BatchProgressListener progressListener; + + @Mock + private BatchMetrics batchMetrics; + + @Test + @DisplayName("beforeJob resets progress listener and counts users") + void resetsProgressAndCountsUsersBeforeJob() { + JobExecution jobExecution = new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new JobParameters()); + when(userRepository.count()).thenReturn(150L); + + listener.beforeJob(jobExecution); + + verify(progressListener).reset(); + verify(userRepository).count(); + } + + @Test + @DisplayName("afterJob records completed metrics from score recalculation step") + void recordsSuccessMetrics() { + JobExecution jobExecution = new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new JobParameters()); + StepExecution stepExecution = jobExecution.createStepExecution("scoreRecalculationStep"); + stepExecution.setReadCount(100); + stepExecution.setWriteCount(92); + stepExecution.setProcessSkipCount(3); + stepExecution.setWriteSkipCount(2); + stepExecution.setFilterCount(5); + jobExecution.setStatus(BatchStatus.COMPLETED); + jobExecution.setStartTime(LocalDateTime.of(2026, 4, 16, 10, 0, 0)); + jobExecution.setEndTime(LocalDateTime.of(2026, 4, 16, 10, 0, 2)); + + listener.afterJob(jobExecution); + + verify(batchMetrics).recordJobCompleted(2000L); + verify(batchMetrics, never()).recordJobFailed(org.mockito.ArgumentMatchers.anyLong()); + verify(batchMetrics).recordItemsProcessed(92); + verify(batchMetrics).recordItemsSkipped(5); + } + + @Test + @DisplayName("afterJob records failed metrics and ignores non-score steps") + void recordsFailureMetricsAndIgnoresOtherSteps() { + JobExecution jobExecution = new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new JobParameters()); + StepExecution stepExecution = jobExecution.createStepExecution("cleanupStep"); + stepExecution.setReadCount(20); + stepExecution.setWriteCount(18); + stepExecution.setFilterCount(1); + jobExecution.setStatus(BatchStatus.FAILED); + + listener.afterJob(jobExecution); + + verify(batchMetrics).recordJobFailed(0L); + verify(batchMetrics, never()).recordJobCompleted(org.mockito.ArgumentMatchers.anyLong()); + verify(batchMetrics).recordItemsProcessed(0); + verify(batchMetrics).recordItemsSkipped(0); + } +} diff --git a/src/test/java/com/gitranker/api/batch/listener/UserScoreCalculationSkipListenerTest.java b/src/test/java/com/gitranker/api/batch/listener/UserScoreCalculationSkipListenerTest.java new file mode 100644 index 0000000..15c745e --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/listener/UserScoreCalculationSkipListenerTest.java @@ -0,0 +1,72 @@ +package com.gitranker.api.batch.listener; + +import com.gitranker.api.domain.failure.BatchFailureLogService; +import com.gitranker.api.domain.user.User; +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException; +import com.gitranker.api.global.error.exception.GitHubApiRetryableException; +import com.gitranker.api.support.TestFixtures; +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 static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserScoreCalculationSkipListenerTest { + + private static final String JOB_NAME_DAILY_SCORE = "DailyScoreRecalculationJob"; + private static final String UNKNOWN_USER_ID = "UNKNOWN_USER"; + + @InjectMocks + private UserScoreCalculationSkipListener listener; + + @Mock + private BatchFailureLogService batchFailureLogService; + + @Test + @DisplayName("read skip stores retryable error metadata for unknown user") + void logsRetryableReadSkip() { + listener.onSkipInRead(new GitHubApiRetryableException(ErrorType.GITHUB_API_TIMEOUT, "timeout")); + + verify(batchFailureLogService).saveFailureLog( + JOB_NAME_DAILY_SCORE, + UNKNOWN_USER_ID, + ErrorType.GITHUB_API_TIMEOUT, + "[READ_PHASE] error.github.api-timeout: timeout" + ); + } + + @Test + @DisplayName("process skip stores default error type for generic failures") + void logsGenericProcessSkip() { + User user = TestFixtures.user("alice"); + + listener.onSkipInProcess(user, new IllegalStateException("boom")); + + verify(batchFailureLogService).saveFailureLog( + JOB_NAME_DAILY_SCORE, + "alice", + ErrorType.DEFAULT_ERROR, + "[PROCESS_PHASE] boom" + ); + } + + @Test + @DisplayName("write skip stores non-retryable GitHub error for the target user") + void logsNonRetryableWriteSkip() { + User user = TestFixtures.user("alice"); + + listener.onSkipInWrite(user, new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND)); + + verify(batchFailureLogService).saveFailureLog( + JOB_NAME_DAILY_SCORE, + "alice", + ErrorType.GITHUB_USER_NOT_FOUND, + "[WRITE_PHASE] error.github.user-not-found" + ); + } +} diff --git a/src/test/java/com/gitranker/api/batch/metrics/BatchMetricsTest.java b/src/test/java/com/gitranker/api/batch/metrics/BatchMetricsTest.java new file mode 100644 index 0000000..3797d18 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/metrics/BatchMetricsTest.java @@ -0,0 +1,53 @@ +package com.gitranker.api.batch.metrics; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BatchMetricsTest { + + @Test + @DisplayName("successful job recording increments success counter and timer") + void recordsCompletedJob() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BatchMetrics metrics = new BatchMetrics(registry); + + metrics.recordJobCompleted(1250); + + assertThat(registry.get("batch_jobs_completed_total").counter().count()).isEqualTo(1.0); + assertThat(registry.get("batch_job_duration").tag("status", "success").timer().count()).isEqualTo(1L); + assertThat(registry.get("batch_job_duration").tag("status", "success").timer().totalTime(java.util.concurrent.TimeUnit.MILLISECONDS)) + .isEqualTo(1250.0); + } + + @Test + @DisplayName("failed job recording increments failure counter and timer") + void recordsFailedJob() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BatchMetrics metrics = new BatchMetrics(registry); + + metrics.recordJobFailed(500); + + assertThat(registry.get("batch_jobs_failed_total").counter().count()).isEqualTo(1.0); + assertThat(registry.get("batch_job_duration").tag("status", "failure").timer().count()).isEqualTo(1L); + assertThat(registry.get("batch_job_duration").tag("status", "failure").timer().totalTime(java.util.concurrent.TimeUnit.MILLISECONDS)) + .isEqualTo(500.0); + } + + @Test + @DisplayName("item counters accumulate processed and skipped counts") + void recordsProcessedAndSkippedItems() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BatchMetrics metrics = new BatchMetrics(registry); + + metrics.recordItemsProcessed(12); + metrics.recordItemsProcessed(8); + metrics.recordItemsSkipped(3); + metrics.recordItemsSkipped(2); + + assertThat(registry.get("batch_items_processed_total").counter().count()).isEqualTo(20.0); + assertThat(registry.get("batch_items_skipped_total").counter().count()).isEqualTo(5.0); + } +} diff --git a/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java b/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java new file mode 100644 index 0000000..331e3ad --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java @@ -0,0 +1,264 @@ +package com.gitranker.api.batch.processor; + +import com.gitranker.api.batch.strategy.ActivityUpdateContext; +import com.gitranker.api.batch.strategy.FullActivityUpdateStrategy; +import com.gitranker.api.batch.strategy.IncrementalActivityUpdateStrategy; +import com.gitranker.api.domain.log.ActivityLog; +import com.gitranker.api.domain.log.ActivityLogRepository; +import com.gitranker.api.domain.log.ActivityLogService; +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.BusinessException; +import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException; +import com.gitranker.api.global.error.exception.GitHubApiRetryableException; +import com.gitranker.api.global.logging.LogSanitizer; +import com.gitranker.api.infrastructure.github.GitHubActivityService; +import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse; +import com.gitranker.api.support.TestFixtures; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +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.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScoreRecalculationProcessorTest { + + @InjectMocks + private ScoreRecalculationProcessor processor; + + @Mock + private ActivityLogRepository activityLogRepository; + + @Mock + private ActivityLogService activityLogService; + + @Mock + private IncrementalActivityUpdateStrategy incrementalStrategy; + + @Mock + private FullActivityUpdateStrategy fullStrategy; + + @Mock + private GitHubActivityService gitHubActivityService; + + @Test + @DisplayName("processor uses incremental strategy when a baseline log exists") + void usesIncrementalStrategyWhenBaselineExists() { + User user = TestFixtures.user("alice"); + LocalDate today = LocalDate.now(); + int currentYear = today.getYear(); + LocalDate currentYearStart = LocalDate.of(currentYear, 1, 1); + ActivityLog latestLog = ActivityLog.of( + user, + TestFixtures.stats(4, 1, 1, 1, 2), + TestFixtures.stats(1, 0, 0, 0, 1), + LocalDate.of(2025, 12, 31) + ); + ActivityLog baselineLog = ActivityLog.baseline( + user, + TestFixtures.stats(10, 3, 2, 7, 5), + LocalDate.of(2025, 1, 1) + ); + ActivityStatistics updatedStats = TestFixtures.stats(12, 4, 3, 8, 6); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(latestLog); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + eq(currentYearStart) + )).thenReturn(Optional.of(baselineLog)); + when(incrementalStrategy.update(eq(user), any(ActivityUpdateContext.class))).thenReturn(updatedStats); + + User processed = processor.process(user); + + assertThat(processed).isSameAs(user); + assertThat(user.getTotalScore()).isEqualTo(updatedStats.calculateScore().getValue()); + verify(fullStrategy, never()).update(any(), any()); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ActivityUpdateContext.class); + verify(incrementalStrategy).update(eq(user), contextCaptor.capture()); + assertThat(contextCaptor.getValue().baselineLog()).isSameAs(baselineLog); + assertThat(contextCaptor.getValue().currentYear()).isEqualTo(currentYear); + + ArgumentCaptor diffCaptor = ArgumentCaptor.forClass(ActivityStatistics.class); + ArgumentCaptor dateCaptor = ArgumentCaptor.forClass(LocalDate.class); + verify(activityLogService).saveActivityLog(eq(user), eq(updatedStats), diffCaptor.capture(), dateCaptor.capture()); + assertThat(diffCaptor.getValue()).isEqualTo(updatedStats.calculateDiff(latestLog.toStatistics())); + assertThat(dateCaptor.getValue()).isEqualTo(today); + } + + @Test + @DisplayName("processor uses full strategy and empty diff when no previous logs exist") + void usesFullStrategyWhenNoBaselineExists() { + User user = TestFixtures.user("alice"); + LocalDate today = LocalDate.now(); + int currentYear = today.getYear(); + LocalDate currentYearStart = LocalDate.of(currentYear, 1, 1); + ActivityStatistics updatedStats = TestFixtures.stats(3, 2, 1, 4, 5); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + eq(currentYearStart) + )).thenReturn(Optional.empty()); + when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))).thenReturn(updatedStats); + + User processed = processor.process(user); + + assertThat(processed).isSameAs(user); + assertThat(user.getTotalScore()).isEqualTo(updatedStats.calculateScore().getValue()); + verify(incrementalStrategy, never()).update(any(), any()); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ActivityUpdateContext.class); + verify(fullStrategy).update(eq(user), contextCaptor.capture()); + assertThat(contextCaptor.getValue().baselineLog()).isNull(); + assertThat(contextCaptor.getValue().currentYear()).isEqualTo(currentYear); + + verify(activityLogService).saveActivityLog( + eq(user), + eq(updatedStats), + eq(updatedStats.calculateDiff(ActivityStatistics.empty())), + eq(today) + ); + } + + @Test + @DisplayName("processor refreshes profile by node id and retries when username changed") + void refreshesProfileAndRetriesWhenUsernameChanged() { + User user = TestFixtures.user("old-name"); + ActivityStatistics updatedStats = TestFixtures.stats(9, 1, 2, 3, 4); + GitHubNodeUserResponse response = new GitHubNodeUserResponse( + new GitHubNodeUserResponse.Data( + new GitHubNodeUserResponse.Node("node", "new-name", "new@example.com", "https://images.example.com/new.png"), + null + ) + ); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + any(LocalDate.class) + )).thenReturn(Optional.empty()); + when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))) + .thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND)) + .thenReturn(updatedStats); + when(gitHubActivityService.fetchUserByNodeId(user.getNodeId())).thenReturn(response); + + User processed = processor.process(user); + + assertThat(processed).isSameAs(user); + assertThat(user.getUsername()).isEqualTo("new-name"); + assertThat(user.getEmail()).isEqualTo("new@example.com"); + assertThat(user.getProfileImage()).isEqualTo("https://images.example.com/new.png"); + assertThat(user.getTotalScore()).isEqualTo(updatedStats.calculateScore().getValue()); + + verify(gitHubActivityService).fetchUserByNodeId(user.getNodeId()); + verify(fullStrategy, times(2)).update(eq(user), any(ActivityUpdateContext.class)); + verify(activityLogService).saveActivityLog( + eq(user), + eq(updatedStats), + eq(updatedStats), + eq(LocalDate.now()) + ); + } + + @Test + @DisplayName("processor keeps user-not-found when node lookup cannot resolve a replacement user") + void rethrowsUserNotFoundWhenNodeLookupFails() { + User user = TestFixtures.user("alice"); + GitHubNodeUserResponse response = new GitHubNodeUserResponse( + new GitHubNodeUserResponse.Data(null, null) + ); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + any(LocalDate.class) + )).thenReturn(Optional.empty()); + when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))) + .thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND)); + when(gitHubActivityService.fetchUserByNodeId(user.getNodeId())).thenReturn(response); + + assertThatThrownBy(() -> processor.process(user)) + .isInstanceOf(GitHubApiNonRetryableException.class) + .extracting("errorType") + .isEqualTo(ErrorType.GITHUB_USER_NOT_FOUND); + + verify(activityLogService, never()).saveActivityLog(any(), any(), any(), any()); + } + + @Test + @DisplayName("processor propagates retryable GitHub errors without wrapping them") + void propagatesRetryableGitHubError() { + User user = TestFixtures.user("alice"); + GitHubApiRetryableException exception = new GitHubApiRetryableException(ErrorType.GITHUB_API_TIMEOUT); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + any(LocalDate.class) + )).thenReturn(Optional.empty()); + when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))).thenThrow(exception); + + assertThatThrownBy(() -> processor.process(user)) + .isSameAs(exception); + } + + @Test + @DisplayName("processor propagates non-retryable GitHub errors other than username-changed") + void propagatesNonRetryableGitHubError() { + User user = TestFixtures.user("alice"); + GitHubApiNonRetryableException exception = + new GitHubApiNonRetryableException(ErrorType.GITHUB_COLLECT_ACTIVITY_FAILED); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + any(LocalDate.class) + )).thenReturn(Optional.empty()); + when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))).thenThrow(exception); + + assertThatThrownBy(() -> processor.process(user)) + .isSameAs(exception); + } + + @Test + @DisplayName("processor wraps unexpected failures with batch-step business exception") + void wrapsUnexpectedFailure() { + User user = TestFixtures.user("alice"); + + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( + eq(user), + any(LocalDate.class) + )).thenReturn(Optional.empty()); + when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))) + .thenThrow(new IllegalStateException("unexpected")); + + assertThatThrownBy(() -> processor.process(user)) + .isInstanceOf(BusinessException.class) + .satisfies(throwable -> { + BusinessException exception = (BusinessException) throwable; + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BATCH_STEP_FAILED); + assertThat(exception.getData()).isEqualTo("사용자 해시: " + LogSanitizer.hashUsername(user.getUsername())); + }); + + verify(activityLogService, never()).saveActivityLog(any(), any(), any(), any()); + } +} diff --git a/src/test/java/com/gitranker/api/batch/reader/UserItemReaderTest.java b/src/test/java/com/gitranker/api/batch/reader/UserItemReaderTest.java new file mode 100644 index 0000000..6cba182 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/reader/UserItemReaderTest.java @@ -0,0 +1,36 @@ +package com.gitranker.api.batch.reader; + +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.UserRepository; +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.batch.item.data.RepositoryItemReader; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class UserItemReaderTest { + + @InjectMocks + private UserItemReader userItemReader; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("reader is configured with repository pagination and ascending id sort") + void createsConfiguredRepositoryReader() { + RepositoryItemReader reader = userItemReader.createReader(37); + + assertThat(ReflectionTestUtils.getField(reader, "repository")).isSameAs(userRepository); + assertThat(ReflectionTestUtils.getField(reader, "methodName")).isEqualTo("findAll"); + assertThat(ReflectionTestUtils.getField(reader, "pageSize")).isEqualTo(37); + assertThat(ReflectionTestUtils.getField(reader, "sort")).isEqualTo(Sort.by(Sort.Direction.ASC, "id")); + } +} diff --git a/src/test/java/com/gitranker/api/batch/scheduler/BatchSchedulerTest.java b/src/test/java/com/gitranker/api/batch/scheduler/BatchSchedulerTest.java new file mode 100644 index 0000000..242f470 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/scheduler/BatchSchedulerTest.java @@ -0,0 +1,72 @@ +package com.gitranker.api.batch.scheduler; + +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.BusinessException; +import com.gitranker.api.global.logging.LogContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.launch.JobLauncher; + +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.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BatchSchedulerTest { + + @InjectMocks + private BatchScheduler scheduler; + + @Mock + private JobLauncher jobLauncher; + + @Mock + private Job dailyScoreRecalculationJob; + + @AfterEach + void tearDown() { + LogContext.clear(); + } + + @Test + @DisplayName("scheduler launches the batch job with runtime parameter and clears log context") + void launchesJob() throws Exception { + when(jobLauncher.run(eq(dailyScoreRecalculationJob), any(JobParameters.class))).thenReturn(new JobExecution(1L)); + + scheduler.runDailyScoreRecalculationJob(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JobParameters.class); + verify(jobLauncher).run(eq(dailyScoreRecalculationJob), captor.capture()); + assertThat(captor.getValue().getLocalDateTime("runTime")).isNotNull(); + assertThat(LogContext.getTraceId()).isNull(); + } + + @Test + @DisplayName("scheduler wraps launcher failure as batch job business exception and clears log context") + void wrapsLauncherFailure() throws Exception { + when(jobLauncher.run(eq(dailyScoreRecalculationJob), any(JobParameters.class))) + .thenThrow(new IllegalStateException("job already running")); + + assertThatThrownBy(() -> scheduler.runDailyScoreRecalculationJob()) + .isInstanceOf(BusinessException.class) + .satisfies(throwable -> { + BusinessException exception = (BusinessException) throwable; + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BATCH_JOB_FAILED); + assertThat(exception.getData()).isEqualTo("job already running"); + }); + + assertThat(LogContext.getTraceId()).isNull(); + } +} diff --git a/src/test/java/com/gitranker/api/batch/strategy/ActivityUpdateContextTest.java b/src/test/java/com/gitranker/api/batch/strategy/ActivityUpdateContextTest.java new file mode 100644 index 0000000..8a1ebab --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/strategy/ActivityUpdateContextTest.java @@ -0,0 +1,47 @@ +package com.gitranker.api.batch.strategy; + +import com.gitranker.api.domain.log.ActivityLog; +import com.gitranker.api.global.error.message.BatchMessages; +import com.gitranker.api.support.TestFixtures; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ActivityUpdateContextTest { + + @Test + @DisplayName("incremental context keeps baseline log and year") + void createsIncrementalContext() { + ActivityLog baselineLog = ActivityLog.baseline( + TestFixtures.user(), + TestFixtures.stats(10, 2, 3, 4, 5), + LocalDate.of(2025, 12, 31) + ); + + ActivityUpdateContext context = ActivityUpdateContext.forIncremental(baselineLog, 2026); + + assertThat(context.baselineLog()).isSameAs(baselineLog); + assertThat(context.currentYear()).isEqualTo(2026); + } + + @Test + @DisplayName("incremental context rejects missing baseline log") + void rejectsMissingBaselineForIncrementalUpdate() { + assertThatThrownBy(() -> ActivityUpdateContext.forIncremental(null, 2026)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(BatchMessages.BASELINE_LOG_REQUIRED_FOR_INCREMENTAL); + } + + @Test + @DisplayName("full context does not require baseline log") + void createsFullContext() { + ActivityUpdateContext context = ActivityUpdateContext.forFull(2026); + + assertThat(context.baselineLog()).isNull(); + assertThat(context.currentYear()).isEqualTo(2026); + } +} diff --git a/src/test/java/com/gitranker/api/batch/strategy/FullActivityUpdateStrategyTest.java b/src/test/java/com/gitranker/api/batch/strategy/FullActivityUpdateStrategyTest.java new file mode 100644 index 0000000..3e8bc59 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/strategy/FullActivityUpdateStrategyTest.java @@ -0,0 +1,59 @@ +package com.gitranker.api.batch.strategy; + +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import com.gitranker.api.infrastructure.github.GitHubActivityService; +import com.gitranker.api.infrastructure.github.dto.GitHubActivitySummary; +import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; +import com.gitranker.api.support.TestFixtures; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FullActivityUpdateStrategyTest { + + @InjectMocks + private FullActivityUpdateStrategy strategy; + + @Mock + private GitHubActivityService activityService; + + @Test + @DisplayName("full update fetches all activities and converts them to statistics") + void updatesWithFullGitHubScan() { + User user = TestFixtures.user("alice"); + GitHubAllActivitiesResponse response = GitHubAllActivitiesResponse.empty(); + GitHubActivitySummary summary = new GitHubActivitySummary(7, 3, 4, 2, 5); + + when(activityService.fetchRawAllActivities(user.getUsername(), user.getGithubCreatedAt())).thenReturn(response); + when(activityService.toSummary(response)).thenReturn(summary); + + ActivityStatistics statistics = strategy.update(user, ActivityUpdateContext.forFull(2026)); + + assertThat(statistics).isEqualTo(ActivityStatistics.of(7, 2, 3, 4, 5)); + verify(activityService).fetchRawAllActivities(user.getUsername(), user.getGithubCreatedAt()); + verify(activityService).toSummary(response); + } + + @Test + @DisplayName("full update propagates GitHub activity service failures") + void propagatesActivityServiceFailure() { + User user = TestFixtures.user("alice"); + + when(activityService.fetchRawAllActivities(user.getUsername(), user.getGithubCreatedAt())) + .thenThrow(new IllegalStateException("github unavailable")); + + assertThatThrownBy(() -> strategy.update(user, ActivityUpdateContext.forFull(2026))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("github unavailable"); + } +} diff --git a/src/test/java/com/gitranker/api/batch/strategy/IncrementalActivityUpdateStrategyTest.java b/src/test/java/com/gitranker/api/batch/strategy/IncrementalActivityUpdateStrategyTest.java new file mode 100644 index 0000000..8b8364a --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/strategy/IncrementalActivityUpdateStrategyTest.java @@ -0,0 +1,68 @@ +package com.gitranker.api.batch.strategy; + +import com.gitranker.api.domain.log.ActivityLog; +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import com.gitranker.api.infrastructure.github.GitHubActivityService; +import com.gitranker.api.infrastructure.github.dto.GitHubActivitySummary; +import com.gitranker.api.support.TestFixtures; +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IncrementalActivityUpdateStrategyTest { + + @InjectMocks + private IncrementalActivityUpdateStrategy strategy; + + @Mock + private GitHubActivityService activityService; + + @Test + @DisplayName("incremental update merges baseline counts with current-year summary") + void mergesBaselineWithCurrentYearSummary() { + User user = TestFixtures.user("alice"); + ActivityLog baselineLog = ActivityLog.baseline( + user, + TestFixtures.stats(10, 5, 4, 9, 3), + LocalDate.of(2025, 12, 31) + ); + GitHubActivitySummary summary = new GitHubActivitySummary(7, 2, 8, 1, 4); + + when(activityService.fetchActivityForYear(user.getUsername(), 2026)).thenReturn(summary); + + ActivityStatistics statistics = strategy.update(user, ActivityUpdateContext.forIncremental(baselineLog, 2026)); + + assertThat(statistics).isEqualTo(ActivityStatistics.of(17, 6, 6, 8, 7)); + verify(activityService).fetchActivityForYear(user.getUsername(), 2026); + } + + @Test + @DisplayName("incremental update propagates GitHub fetch failures") + void propagatesFetchFailure() { + User user = TestFixtures.user("alice"); + ActivityLog baselineLog = ActivityLog.baseline( + user, + TestFixtures.stats(1, 1, 1, 1, 1), + LocalDate.of(2025, 12, 31) + ); + + when(activityService.fetchActivityForYear(user.getUsername(), 2026)) + .thenThrow(new IllegalArgumentException("bad response")); + + assertThatThrownBy(() -> strategy.update(user, ActivityUpdateContext.forIncremental(baselineLog, 2026))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bad response"); + } +} diff --git a/src/test/java/com/gitranker/api/batch/tasklet/RankingRecalculationTaskletTest.java b/src/test/java/com/gitranker/api/batch/tasklet/RankingRecalculationTaskletTest.java new file mode 100644 index 0000000..709fa6a --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/tasklet/RankingRecalculationTaskletTest.java @@ -0,0 +1,72 @@ +package com.gitranker.api.batch.tasklet; + +import com.gitranker.api.domain.user.UserRepository; +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.BusinessException; +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.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.repeat.RepeatStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class RankingRecalculationTaskletTest { + + @InjectMocks + private RankingRecalculationTasklet tasklet; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("tasklet performs ranking bulk update and finishes") + void recalculatesRankings() throws Exception { + RepeatStatus status = tasklet.execute(stepContribution(), chunkContext()); + + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(userRepository).bulkUpdateRanking(); + } + + @Test + @DisplayName("tasklet wraps bulk update failures with batch-step exception") + void wrapsBulkUpdateFailure() { + doThrow(new RuntimeException("bulk failed")).when(userRepository).bulkUpdateRanking(); + + assertThatThrownBy(() -> tasklet.execute(stepContribution(), chunkContext())) + .isInstanceOf(BusinessException.class) + .satisfies(throwable -> { + BusinessException exception = (BusinessException) throwable; + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BATCH_STEP_FAILED); + assertThat(exception.getData()).isEqualTo("랭킹 재산정 실패"); + }); + } + + private StepContribution stepContribution() { + StepExecution stepExecution = new StepExecution( + "rankingStep", + new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new org.springframework.batch.core.JobParameters()) + ); + return new StepContribution(stepExecution); + } + + private ChunkContext chunkContext() { + StepExecution stepExecution = new StepExecution( + "rankingStep", + new JobExecution(new JobInstance(1L, "DailyScoreRecalculationJob"), new org.springframework.batch.core.JobParameters()) + ); + return new ChunkContext(new StepContext(stepExecution)); + } +} diff --git a/src/test/java/com/gitranker/api/batch/writer/UserItemWriterTest.java b/src/test/java/com/gitranker/api/batch/writer/UserItemWriterTest.java new file mode 100644 index 0000000..9877e57 --- /dev/null +++ b/src/test/java/com/gitranker/api/batch/writer/UserItemWriterTest.java @@ -0,0 +1,59 @@ +package com.gitranker.api.batch.writer; + +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.UserRepository; +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.BusinessException; +import com.gitranker.api.support.TestFixtures; +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.batch.item.Chunk; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserItemWriterTest { + + @InjectMocks + private UserItemWriter userItemWriter; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("writer saves all users in the chunk") + void savesChunkUsers() throws Exception { + User first = TestFixtures.user("alice"); + User second = TestFixtures.user("bob"); + Chunk chunk = new Chunk<>(List.of(first, second)); + + userItemWriter.write(chunk); + + verify(userRepository).saveAll(chunk.getItems()); + } + + @Test + @DisplayName("writer wraps repository failures with batch-step exception") + void wrapsRepositoryFailure() { + Chunk chunk = new Chunk<>(List.of(TestFixtures.user("alice"))); + + when(userRepository.saveAll(chunk.getItems())).thenThrow(new RuntimeException("db down")); + + assertThatThrownBy(() -> userItemWriter.write(chunk)) + .isInstanceOf(BusinessException.class) + .satisfies(throwable -> { + BusinessException exception = (BusinessException) throwable; + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BATCH_STEP_FAILED); + assertThat(exception.getData()).isEqualTo("DB 저장 실패"); + }); + } +} diff --git a/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java b/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java index 424b19c..3e09282 100644 --- a/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -88,6 +89,8 @@ void deletesExpiredRefreshTokenBeforeThrowing() { verify(refreshTokenRepository).delete(expiredToken); verify(authCookieManager, never()).addAccessTokenCookie(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + verify(authCookieManager, never()).addRefreshTokenCookie(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + verifyNoInteractions(refreshTokenService); } @Test @@ -125,6 +128,41 @@ void throwsForbiddenForOtherUsersToken() { .isEqualTo(ErrorType.FORBIDDEN); } + @Test + @DisplayName("logout 대상 refresh token이 없으면 INVALID_REFRESH_TOKEN 예외가 발생한다") + void throwsWhenLogoutTokenDoesNotExist() { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + when(refreshTokenRepository.findByToken("missing")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.logout(savedUser(1L, "alice"), "missing", request, response)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException) exception).getErrorType()) + .isEqualTo(ErrorType.INVALID_REFRESH_TOKEN); + + verify(refreshTokenRepository, never()).deleteByToken("missing"); + verify(refreshTokenRepository, never()).delete(org.mockito.ArgumentMatchers.any(RefreshToken.class)); + verifyNoInteractions(authCookieManager, refreshTokenService, jwtProvider, request, response); + } + + @Test + @DisplayName("logout 시 세션이 없어도 쿠키와 token 정리는 수행한다") + void logsOutWithoutSessionWhenNoHttpSessionExists() { + User user = savedUser(1L, "alice"); + RefreshToken refreshToken = refreshToken(user, "valid-token", validExpiry()); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + + when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(refreshToken)); + when(request.getSession(false)).thenReturn(null); + + authService.logout(user, "valid-token", request, response); + + verify(refreshTokenRepository).deleteByToken("valid-token"); + verify(authCookieManager).clearAccessTokenCookie(response); + verify(authCookieManager).clearRefreshTokenCookie(response); + } + @Test @DisplayName("logoutAll은 사용자의 모든 token과 쿠키를 정리한다") void logoutAllClearsAllTokensAndCookies() { diff --git a/src/test/java/com/gitranker/api/domain/badge/BadgeFormatterTest.java b/src/test/java/com/gitranker/api/domain/badge/BadgeFormatterTest.java index 237171e..2bcf288 100644 --- a/src/test/java/com/gitranker/api/domain/badge/BadgeFormatterTest.java +++ b/src/test/java/com/gitranker/api/domain/badge/BadgeFormatterTest.java @@ -37,4 +37,11 @@ void calculatesTierFontSizeByLength() { assertThat(badgeFormatter.calculateTierFontSize("Diamond")).isEqualTo(30); assertThat(badgeFormatter.calculateTierFontSize("Challenger")).isEqualTo(26); } + + @Test + @DisplayName("폰트 크기 경계값 6자와 7자는 서로 다른 크기를 사용한다") + void calculatesTierFontSizeAtBoundaries() { + assertThat(badgeFormatter.calculateTierFontSize("Silver")).isEqualTo(32); + assertThat(badgeFormatter.calculateTierFontSize("MasterX")).isEqualTo(30); + } } diff --git a/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java b/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java index 69ef60b..03fbd9a 100644 --- a/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java @@ -80,6 +80,7 @@ void rendersBadgeWithLatestActivityLog() { String badge = badgeService.generateBadge("node-alice"); assertThat(badge).isEqualTo("badge"); + verify(svgBadgeRenderer).render(user, user.getTier(), latestLog); verify(businessMetrics).incrementBadgeViews(); } @@ -119,6 +120,9 @@ void rendersPreviewBadgeByTier() { assertThat(userCaptor.getValue().getUsername()).isEqualTo("DIAMOND"); assertThat(userCaptor.getValue().getTotalScore()).isEqualTo(12345); + assertThat(userCaptor.getValue().getRanking()).isEqualTo(1); assertThat(logCaptor.getValue().getMergedPrCount()).isEqualTo(25); + assertThat(logCaptor.getValue().getDiffCommitCount()).isEqualTo(12); + verifyNoInteractions(businessMetrics); } } diff --git a/src/test/java/com/gitranker/api/domain/badge/SvgBadgeRendererTest.java b/src/test/java/com/gitranker/api/domain/badge/SvgBadgeRendererTest.java index 05014df..17fb679 100644 --- a/src/test/java/com/gitranker/api/domain/badge/SvgBadgeRendererTest.java +++ b/src/test/java/com/gitranker/api/domain/badge/SvgBadgeRendererTest.java @@ -42,4 +42,19 @@ void rendersSvgWithUserTierAndStats() { .contains("diff-minus") .contains("-2"); } + + @Test + @DisplayName("diff가 0인 항목은 증감 마크업이 렌더링되지 않는다") + void doesNotRenderDiffMarkupForZeroDiff() { + User user = user("alice"); + user.updateScore(Score.of(1234)); + user.updateRankInfo(RankInfo.of(10, 55.0, 1234)); + ActivityLog activityLog = activityLog(user, stats(1, 2, 3, 4, 5), stats(0, 0, 0, 0, 0)); + + String svg = svgBadgeRenderer.render(user, Tier.IRON, activityLog); + + assertThat(svg).doesNotContain("+0"); + assertThat(svg).doesNotContain("-0"); + assertThat(svg).contains("font-size: 32px"); + } } diff --git a/src/test/java/com/gitranker/api/domain/log/ActivityLogOrchestratorTest.java b/src/test/java/com/gitranker/api/domain/log/ActivityLogOrchestratorTest.java new file mode 100644 index 0000000..f76f224 --- /dev/null +++ b/src/test/java/com/gitranker/api/domain/log/ActivityLogOrchestratorTest.java @@ -0,0 +1,130 @@ +package com.gitranker.api.domain.log; + +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.vo.ActivityStatistics; +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 java.time.LocalDate; +import java.util.Optional; + +import static com.gitranker.api.support.TestFixtures.activityLog; +import static com.gitranker.api.support.TestFixtures.stats; +import static com.gitranker.api.support.TestFixtures.user; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ActivityLogOrchestratorTest { + + @InjectMocks + private ActivityLogOrchestrator activityLogOrchestrator; + + @Mock + private ActivityLogService activityLogService; + + @Test + @DisplayName("신규 사용자에 baseline 통계가 있으면 작년 말 baseline과 오늘 로그를 함께 저장한다") + void createsBaselineAndTodayLogForNewUser() { + User user = user("alice"); + ActivityStatistics totalStats = stats(10, 2, 3, 4, 5); + ActivityStatistics baselineStats = stats(4, 1, 1, 2, 2); + LocalDate today = LocalDate.now(); + + activityLogOrchestrator.createLogsForNewUser(user, totalStats, baselineStats); + + verify(activityLogService).saveBaselineLog(user, baselineStats, LocalDate.of(today.getYear() - 1, 12, 31)); + verify(activityLogService).saveActivityLog(user, totalStats, ActivityStatistics.empty(), today); + } + + @Test + @DisplayName("신규 사용자에 baseline 통계가 없으면 오늘 로그만 저장한다") + void skipsBaselineLogWhenNotProvidedForNewUser() { + User user = user("alice"); + ActivityStatistics totalStats = stats(10, 2, 3, 4, 5); + LocalDate today = LocalDate.now(); + + activityLogOrchestrator.createLogsForNewUser(user, totalStats, null); + + verify(activityLogService, never()).saveBaselineLog(any(User.class), any(ActivityStatistics.class), any(LocalDate.class)); + verify(activityLogService).saveActivityLog(user, totalStats, ActivityStatistics.empty(), today); + } + + @Test + @DisplayName("오늘 로그와 전일 로그가 있으면 refresh 시 diff를 계산해 오늘 로그를 갱신한다") + void updatesExistingTodayLogWithPreviousDayDiff() { + User user = user("alice"); + ActivityLog todayLog = activityLog(user, stats(4, 1, 1, 0, 2), ActivityStatistics.empty()); + ActivityLog previousLog = activityLog(user, stats(8, 2, 1, 2, 3), ActivityStatistics.empty()); + ActivityStatistics totalStats = stats(12, 5, 4, 3, 6); + ActivityStatistics baselineStats = stats(2, 1, 1, 0, 1); + LocalDate today = LocalDate.now(); + LocalDate baselineDate = LocalDate.of(today.getYear() - 1, 12, 31); + + when(activityLogService.findByDate(user, baselineDate)).thenReturn(Optional.of(previousLog)); + when(activityLogService.findByDate(user, today)).thenReturn(Optional.of(todayLog)); + when(activityLogService.findPreviousDayLog(user, today)).thenReturn(Optional.of(previousLog)); + + activityLogOrchestrator.updateLogsForRefresh(user, totalStats, baselineStats); + + verify(activityLogService).updateBaselineLog(previousLog, baselineStats); + verify(activityLogService).updateActivityLog(todayLog, totalStats, totalStats.calculateDiff(previousLog.toStatistics())); + } + + @Test + @DisplayName("오늘 로그는 있지만 전일 로그가 없으면 zero diff로 오늘 로그를 갱신한다") + void updatesExistingTodayLogWithZeroDiffWhenPreviousLogIsMissing() { + User user = user("alice"); + ActivityLog todayLog = activityLog(user, stats(4, 1, 1, 0, 2), ActivityStatistics.empty()); + ActivityStatistics totalStats = stats(12, 5, 4, 3, 6); + LocalDate today = LocalDate.now(); + + when(activityLogService.findByDate(user, today)).thenReturn(Optional.of(todayLog)); + when(activityLogService.findPreviousDayLog(user, today)).thenReturn(Optional.empty()); + + activityLogOrchestrator.updateLogsForRefresh(user, totalStats, null); + + verify(activityLogService).updateActivityLog(todayLog, totalStats, ActivityStatistics.empty()); + verify(activityLogService, never()).updateBaselineLog(any(ActivityLog.class), any(ActivityStatistics.class)); + } + + @Test + @DisplayName("오늘 로그가 없고 전일 로그가 있으면 diff를 계산해 새 오늘 로그를 저장한다") + void createsTodayLogWithPreviousDayDiff() { + User user = user("alice"); + ActivityLog previousLog = activityLog(user, stats(8, 2, 1, 2, 3), ActivityStatistics.empty()); + ActivityStatistics totalStats = stats(12, 5, 4, 3, 6); + LocalDate today = LocalDate.now(); + + when(activityLogService.findByDate(user, today)).thenReturn(Optional.empty()); + when(activityLogService.findPreviousDayLog(user, today)).thenReturn(Optional.of(previousLog)); + + activityLogOrchestrator.updateLogsForRefresh(user, totalStats, null); + + verify(activityLogService).saveActivityLog(user, totalStats, totalStats.calculateDiff(previousLog.toStatistics()), today); + } + + @Test + @DisplayName("오늘 로그와 전일 로그가 모두 없으면 zero diff로 새 오늘 로그를 저장한다") + void createsTodayLogWithZeroDiffWhenNoHistoryExists() { + User user = user("alice"); + ActivityStatistics totalStats = stats(12, 5, 4, 3, 6); + LocalDate today = LocalDate.now(); + LocalDate baselineDate = LocalDate.of(today.getYear() - 1, 12, 31); + + when(activityLogService.findByDate(user, baselineDate)).thenReturn(Optional.empty()); + when(activityLogService.findByDate(user, today)).thenReturn(Optional.empty()); + when(activityLogService.findPreviousDayLog(user, today)).thenReturn(Optional.empty()); + + activityLogOrchestrator.updateLogsForRefresh(user, totalStats, stats(1, 1, 1, 1, 1)); + + verify(activityLogService).saveActivityLog(user, totalStats, ActivityStatistics.empty(), today); + verify(activityLogService, never()).updateBaselineLog(any(ActivityLog.class), any(ActivityStatistics.class)); + } +} diff --git a/src/test/java/com/gitranker/api/domain/log/ActivityLogServiceTest.java b/src/test/java/com/gitranker/api/domain/log/ActivityLogServiceTest.java index 2a3c88f..3848de2 100644 --- a/src/test/java/com/gitranker/api/domain/log/ActivityLogServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/log/ActivityLogServiceTest.java @@ -48,7 +48,15 @@ void savesActivityLog() { ActivityLog savedLog = captor.getValue(); assertThat(savedLog.getActivityDate()).isEqualTo(logDate); assertThat(savedLog.getCommitCount()).isEqualTo(10); + assertThat(savedLog.getIssueCount()).isEqualTo(2); + assertThat(savedLog.getPrCount()).isEqualTo(3); + assertThat(savedLog.getMergedPrCount()).isEqualTo(1); + assertThat(savedLog.getReviewCount()).isEqualTo(4); assertThat(savedLog.getDiffCommitCount()).isEqualTo(2); + assertThat(savedLog.getDiffIssueCount()).isEqualTo(1); + assertThat(savedLog.getDiffPrCount()).isZero(); + assertThat(savedLog.getDiffMergedPrCount()).isZero(); + assertThat(savedLog.getDiffReviewCount()).isEqualTo(1); } @Test @@ -63,11 +71,46 @@ void savesBaselineLogWithZeroDiff() { verify(activityLogRepository).save(captor.capture()); ActivityLog baselineLog = captor.getValue(); + assertThat(baselineLog.getCommitCount()).isEqualTo(100); + assertThat(baselineLog.getIssueCount()).isEqualTo(10); + assertThat(baselineLog.getPrCount()).isEqualTo(20); + assertThat(baselineLog.getMergedPrCount()).isEqualTo(15); + assertThat(baselineLog.getReviewCount()).isEqualTo(30); assertThat(baselineLog.getDiffCommitCount()).isZero(); assertThat(baselineLog.getDiffIssueCount()).isZero(); + assertThat(baselineLog.getDiffPrCount()).isZero(); + assertThat(baselineLog.getDiffMergedPrCount()).isZero(); assertThat(baselineLog.getDiffReviewCount()).isZero(); } + @Test + @DisplayName("최신 로그가 있으면 Optional로 감싸 반환한다") + void returnsLatestLogWhenItExists() { + User user = user("alice"); + ActivityLog latestLog = activityLog(user, stats(5, 1, 1, 0, 2), stats(1, 0, 0, 0, 1)); + when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(latestLog); + + Optional result = activityLogService.findLatestLog(user); + + assertThat(result).contains(latestLog); + } + + @Test + @DisplayName("findByDate와 findPreviousDayLog는 repository 결과를 그대로 위임한다") + void delegatesDateBasedLookups() { + User user = user("alice"); + LocalDate date = LocalDate.of(2025, 1, 2); + ActivityLog sameDayLog = activityLog(user, stats(2, 2, 2, 2, 2), ActivityStatistics.empty()); + ActivityLog previousDayLog = activityLog(user, stats(1, 1, 1, 1, 1), ActivityStatistics.empty()); + + when(activityLogRepository.findByUserAndActivityDate(user, date)).thenReturn(Optional.of(sameDayLog)); + when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(user, date)) + .thenReturn(Optional.of(previousDayLog)); + + assertThat(activityLogService.findByDate(user, date)).contains(sameDayLog); + assertThat(activityLogService.findPreviousDayLog(user, date)).contains(previousDayLog); + } + @Test @DisplayName("최신 로그가 없으면 ACTIVITY_LOG_NOT_FOUND 예외가 발생한다") void throwsWhenLatestLogDoesNotExist() { @@ -91,7 +134,34 @@ void updatesExistingActivityLog() { activityLogService.updateActivityLog(activityLog, newTotals, newDiff); assertThat(activityLog.getCommitCount()).isEqualTo(20); + assertThat(activityLog.getIssueCount()).isEqualTo(3); assertThat(activityLog.getPrCount()).isEqualTo(5); + assertThat(activityLog.getMergedPrCount()).isEqualTo(2); + assertThat(activityLog.getReviewCount()).isEqualTo(7); + assertThat(activityLog.getDiffCommitCount()).isEqualTo(3); + assertThat(activityLog.getDiffIssueCount()).isEqualTo(1); + assertThat(activityLog.getDiffPrCount()).isEqualTo(1); assertThat(activityLog.getDiffMergedPrCount()).isEqualTo(1); + assertThat(activityLog.getDiffReviewCount()).isEqualTo(2); + } + + @Test + @DisplayName("baseline 로그 업데이트는 diff를 유지한 채 총계만 갱신한다") + void updatesBaselineLogWithoutTouchingDiff() { + User user = user("alice"); + ActivityLog baselineLog = ActivityLog.baseline(user, stats(10, 2, 3, 4, 5), LocalDate.of(2024, 12, 31)); + + activityLogService.updateBaselineLog(baselineLog, stats(30, 20, 10, 5, 1)); + + assertThat(baselineLog.getCommitCount()).isEqualTo(30); + assertThat(baselineLog.getIssueCount()).isEqualTo(20); + assertThat(baselineLog.getPrCount()).isEqualTo(10); + assertThat(baselineLog.getMergedPrCount()).isEqualTo(5); + assertThat(baselineLog.getReviewCount()).isEqualTo(1); + assertThat(baselineLog.getDiffCommitCount()).isZero(); + assertThat(baselineLog.getDiffIssueCount()).isZero(); + assertThat(baselineLog.getDiffPrCount()).isZero(); + assertThat(baselineLog.getDiffMergedPrCount()).isZero(); + assertThat(baselineLog.getDiffReviewCount()).isZero(); } } diff --git a/src/test/java/com/gitranker/api/domain/log/ActivityLogTest.java b/src/test/java/com/gitranker/api/domain/log/ActivityLogTest.java new file mode 100644 index 0000000..34d8f72 --- /dev/null +++ b/src/test/java/com/gitranker/api/domain/log/ActivityLogTest.java @@ -0,0 +1,72 @@ +package com.gitranker.api.domain.log; + +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static com.gitranker.api.support.TestFixtures.stats; +import static com.gitranker.api.support.TestFixtures.user; +import static org.assertj.core.api.Assertions.assertThat; + +class ActivityLogTest { + + @Test + @DisplayName("일반 활동 로그 생성은 총계와 diff를 각 필드에 매핑한다") + void createsRegularActivityLogFromStatistics() { + User user = user("alice"); + ActivityLog log = ActivityLog.of( + user, + stats(10, 2, 3, 4, 5), + stats(1, -1, 0, 2, 3), + LocalDate.of(2025, 1, 2) + ); + + assertThat(log.getCommitCount()).isEqualTo(10); + assertThat(log.getIssueCount()).isEqualTo(2); + assertThat(log.getPrCount()).isEqualTo(3); + assertThat(log.getMergedPrCount()).isEqualTo(4); + assertThat(log.getReviewCount()).isEqualTo(5); + assertThat(log.getDiffIssueCount()).isEqualTo(-1); + assertThat(log.getDiffMergedPrCount()).isEqualTo(2); + } + + @Test + @DisplayName("baseline과 empty 로그는 diff가 모두 0이다") + void baselineAndEmptyLogsResetDiffs() { + User user = user("alice"); + ActivityLog baseline = ActivityLog.baseline(user, stats(5, 4, 3, 2, 1), LocalDate.of(2024, 12, 31)); + ActivityLog empty = ActivityLog.empty(user, LocalDate.of(2025, 1, 1)); + + assertThat(baseline.getDiffCommitCount()).isZero(); + assertThat(baseline.getDiffReviewCount()).isZero(); + assertThat(empty.toStatistics()).isEqualTo(ActivityStatistics.empty()); + } + + @Test + @DisplayName("toStatistics와 updateStatistics는 총계만 반영한다") + void convertsAndUpdatesStatisticsWithoutChangingDiff() { + User user = user("alice"); + ActivityLog log = ActivityLog.empty(user, LocalDate.of(2025, 1, 1)); + + log.updateStatistics(stats(8, 7, 6, 5, 4)); + + assertThat(log.toStatistics()).isEqualTo(stats(8, 7, 6, 5, 4)); + assertThat(log.getDiffCommitCount()).isZero(); + } + + @Test + @DisplayName("updateStatisticsWithDiff는 총계와 diff를 함께 교체한다") + void updatesStatisticsWithDiff() { + User user = user("alice"); + ActivityLog log = ActivityLog.empty(user, LocalDate.of(2025, 1, 1)); + + log.updateStatisticsWithDiff(stats(9, 8, 7, 6, 5), stats(1, 2, 3, 4, 5)); + + assertThat(log.toStatistics()).isEqualTo(stats(9, 8, 7, 6, 5)); + assertThat(log.getDiffCommitCount()).isEqualTo(1); + assertThat(log.getDiffReviewCount()).isEqualTo(5); + } +} diff --git a/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java b/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java index be7e3d9..c4eeca4 100644 --- a/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java @@ -45,6 +45,7 @@ void skipsRecalculationWithinDebounceWindow() { assertThat(recalculated).isFalse(); verify(userRepository, times(1)).bulkUpdateRanking(); + verify(rankingService, times(1)).evictRankingCache(); } @Test diff --git a/src/test/java/com/gitranker/api/domain/ranking/RankingServiceTest.java b/src/test/java/com/gitranker/api/domain/ranking/RankingServiceTest.java index 348f7a9..32ee88a 100644 --- a/src/test/java/com/gitranker/api/domain/ranking/RankingServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/ranking/RankingServiceTest.java @@ -20,6 +20,7 @@ import static com.gitranker.api.support.TestFixtures.user; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -47,8 +48,12 @@ void getsOverallRankingWhenTierIsNull() { assertThat(rankingList.rankings()).hasSize(1); assertThat(rankingList.rankings().getFirst().username()).isEqualTo("alice"); assertThat(rankingList.pageInfo().currentPage()).isZero(); + assertThat(rankingList.pageInfo().pageSize()).isEqualTo(20); assertThat(rankingList.pageInfo().totalElements()).isEqualTo(1); + assertThat(rankingList.pageInfo().isFirst()).isTrue(); + assertThat(rankingList.pageInfo().isLast()).isTrue(); verify(userRepository).findAllByOrderByScoreValueDesc(pageRequest); + verifyNoMoreInteractions(userRepository); } @Test @@ -67,7 +72,27 @@ void getsTierRankingWhenTierIsProvided() { assertThat(rankingList.rankings()).hasSize(1); assertThat(rankingList.rankings().getFirst().tier()).isEqualTo(Tier.DIAMOND); assertThat(rankingList.pageInfo().currentPage()).isEqualTo(1); + assertThat(rankingList.pageInfo().totalPages()).isEqualTo(2); assertThat(rankingList.pageInfo().totalElements()).isEqualTo(21); verify(userRepository).findAllByRankInfoTierOrderByScoreValueDesc(Tier.DIAMOND, pageRequest); + verifyNoMoreInteractions(userRepository); + } + + @Test + @DisplayName("조회 결과가 비어 있어도 페이지 정보는 유지된다") + void keepsPagingMetadataWhenRankingIsEmpty() { + PageRequest pageRequest = PageRequest.of(2, 20); + when(userRepository.findAllByOrderByScoreValueDesc(pageRequest)) + .thenReturn(new PageImpl<>(List.of(), pageRequest, 41)); + + RankingList rankingList = rankingService.getRankingList(2, null); + + assertThat(rankingList.rankings()).isEmpty(); + assertThat(rankingList.pageInfo().currentPage()).isEqualTo(2); + assertThat(rankingList.pageInfo().totalElements()).isEqualTo(41); + assertThat(rankingList.pageInfo().totalPages()).isEqualTo(3); + assertThat(rankingList.pageInfo().isLast()).isTrue(); + verify(userRepository).findAllByOrderByScoreValueDesc(pageRequest); + verifyNoMoreInteractions(userRepository); } } diff --git a/src/test/java/com/gitranker/api/domain/user/UserTest.java b/src/test/java/com/gitranker/api/domain/user/UserTest.java new file mode 100644 index 0000000..7b0c97a --- /dev/null +++ b/src/test/java/com/gitranker/api/domain/user/UserTest.java @@ -0,0 +1,104 @@ +package com.gitranker.api.domain.user; + +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import com.gitranker.api.domain.user.vo.RankInfo; +import com.gitranker.api.domain.user.vo.Score; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static com.gitranker.api.support.TestFixtures.user; +import static org.assertj.core.api.Assertions.assertThat; + +class UserTest { + + @Test + @DisplayName("사용자는 기본적으로 USER 역할, 0점, IRON 티어로 시작한다") + void initializesWithDefaultRoleScoreAndTier() { + User user = User.builder() + .githubId(1L) + .nodeId("node-alice") + .username("alice") + .build(); + + assertThat(user.getRole()).isEqualTo(Role.USER); + assertThat(user.getTotalScore()).isZero(); + assertThat(user.getTier()).isEqualTo(Tier.IRON); + assertThat(user.isNewUser()).isTrue(); + } + + @Test + @DisplayName("프로필 업데이트는 null과 동일값을 무시하고 변경이 있을 때만 updatedAt을 갱신한다") + void updatesOnlyChangedProfileFields() { + User user = user("alice"); + LocalDateTime previousUpdatedAt = user.getUpdatedAt(); + + boolean unchanged = user.updateProfile("alice", null, "alice@example.com"); + LocalDateTime unchangedUpdatedAt = user.getUpdatedAt(); + boolean changed = user.updateProfile("alice-renamed", null, "alice-renamed@example.com"); + + assertThat(unchanged).isFalse(); + assertThat(changed).isTrue(); + assertThat(unchangedUpdatedAt).isEqualTo(previousUpdatedAt); + assertThat(user.getUsername()).isEqualTo("alice-renamed"); + assertThat(user.getEmail()).isEqualTo("alice-renamed@example.com"); + assertThat(user.getUpdatedAt()).isAfterOrEqualTo(unchangedUpdatedAt); + } + + @Test + @DisplayName("full scan 가능 여부는 null, 쿨다운 전, 쿨다운 이후를 구분한다") + void determinesFullScanAvailabilityFromCooldown() { + User user = user("alice"); + + ReflectionTestUtils.setField(user, "lastFullScanAt", null); + assertThat(user.canTriggerFullScan()).isTrue(); + + ReflectionTestUtils.setField(user, "lastFullScanAt", LocalDateTime.now().minusMinutes(4)); + assertThat(user.canTriggerFullScan()).isFalse(); + + ReflectionTestUtils.setField(user, "lastFullScanAt", LocalDateTime.now().minusMinutes(6)); + assertThat(user.canTriggerFullScan()).isTrue(); + } + + @Test + @DisplayName("recordFullScan과 통계 업데이트는 점수와 랭크를 새 값으로 바꾼다") + void recordsFullScanAndUpdatesRanking() { + User user = user("alice"); + LocalDateTime previousUpdatedAt = user.getUpdatedAt(); + + user.updateActivityStatistics(ActivityStatistics.of(10, 2, 1, 3, 4), 1L, 10L); + user.recordFullScan(); + + assertThat(user.getTotalScore()).isEqualTo(63); + assertThat(user.getRanking()).isEqualTo(2); + assertThat(user.getPercentile()).isEqualTo(20.0); + assertThat(user.getUpdatedAt()).isAfterOrEqualTo(previousUpdatedAt); + assertThat(user.getLastFullScanAt()).isNotNull(); + } + + @Test + @DisplayName("embedded 값이 null이어도 getter는 안전한 기본값을 반환한다") + void returnsFallbackValuesWhenEmbeddedFieldsAreNull() { + User user = user("alice"); + ReflectionTestUtils.setField(user, "score", null); + ReflectionTestUtils.setField(user, "rankInfo", null); + + assertThat(user.getTotalScore()).isZero(); + assertThat(user.getRanking()).isZero(); + assertThat(user.getTier()).isEqualTo(Tier.IRON); + assertThat(user.getPercentile()).isEqualTo(100.0); + } + + @Test + @DisplayName("isAtLeast는 티어 ordinal 기준으로 비교한다") + void checksTierThreshold() { + User user = user("alice"); + user.updateScore(Score.of(2200)); + user.updateRankInfo(RankInfo.of(5, 10.0, 2200)); + + assertThat(user.isAtLeast(Tier.GOLD)).isTrue(); + assertThat(user.isAtLeast(Tier.MASTER)).isFalse(); + } +} diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserDeletionServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserDeletionServiceTest.java index 8838a03..5e6c5c4 100644 --- a/src/test/java/com/gitranker/api/domain/user/service/UserDeletionServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/user/service/UserDeletionServiceTest.java @@ -60,4 +60,19 @@ void deletesUserDataAndClearsRefreshCookie() { .contains("Secure") .contains("SameSite=Lax"); } + + @Test + @DisplayName("secure 옵션이 false면 삭제 쿠키에도 Secure 속성이 붙지 않는다") + void omitsSecureFlagWhenCookieIsNotSecure() { + User user = savedUser(1L, "alice"); + MockHttpServletResponse response = new MockHttpServletResponse(); + ReflectionTestUtils.setField(userDeletionService, "cookieDomain", "localhost"); + ReflectionTestUtils.setField(userDeletionService, "isCookieSecure", false); + + userDeletionService.deleteAccount(user, response); + + assertThat(response.getHeader("Set-Cookie")) + .contains("Domain=localhost") + .doesNotContain("Secure"); + } } diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java index 719f584..b8c3c8e 100644 --- a/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java @@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -78,6 +79,24 @@ void updatesProfileAndSavesUser() { verify(userRepository).save(user); } + @Test + @DisplayName("프로필 값이 바뀌지 않아도 현재 구현은 사용자를 다시 저장한다") + void savesUserEvenWhenProfileValuesAreUnchanged() { + User user = user("alice"); + LocalDateTime previousUpdatedAt = user.getUpdatedAt(); + when(userRepository.save(user)).thenReturn(user); + + User updatedUser = userPersistenceService.updateProfile( + user, + "alice", + user.getProfileImage(), + user.getEmail() + ); + + assertThat(updatedUser.getUpdatedAt()).isEqualTo(previousUpdatedAt); + verify(userRepository).save(user); + } + @Test @DisplayName("통계 갱신 대상 사용자가 없으면 USER_NOT_FOUND 예외가 발생한다") void throwsWhenUserForStatsUpdateDoesNotExist() { @@ -109,7 +128,27 @@ void updatesUserStatisticsAndLogs() { assertThat(updatedUser.getTotalScore()).isEqualTo(totalStats.calculateScore().getValue()); assertThat(updatedUser.getLastFullScanAt()).isAfterOrEqualTo(previousScanTime); + assertThat(updatedUser.getRanking()).isEqualTo(2); verify(activityLogOrchestrator).updateLogsForRefresh(user, totalStats, baselineStats); verify(rankingRecalculationService).recalculateIfNeeded(); } + + @Test + @DisplayName("baseline 통계가 없어도 사용자 통계 업데이트와 로그 갱신은 수행된다") + void updatesUserStatisticsWhenBaselineStatsAreNull() { + User user = savedUser(1L, "alice"); + ActivityStatistics totalStats = stats(20, 2, 1, 0, 1); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.countByScoreValueGreaterThan(anyInt())).thenReturn(5L); + when(userRepository.count()).thenReturn(20L); + + User updatedUser = userPersistenceService.updateUserStatisticsWithLog(1L, totalStats, null); + + assertThat(updatedUser.getTotalScore()).isEqualTo(totalStats.calculateScore().getValue()); + assertThat(updatedUser.getRanking()).isEqualTo(6); + verify(activityLogOrchestrator).updateLogsForRefresh(user, totalStats, null); + verify(rankingRecalculationService).recalculateIfNeeded(); + verifyNoMoreInteractions(rankingRecalculationService); + } } diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java index 839960e..3c95a6c 100644 --- a/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java @@ -73,4 +73,19 @@ void throwsWhenUserDoesNotExist() { verifyNoInteractions(activityLogService, businessMetrics); } + + @Test + @DisplayName("최신 로그 조회가 실패하면 예외를 그대로 전파하고 조회 metric은 증가하지 않는다") + void propagatesActivityLogLookupFailureWithoutIncrementingMetrics() { + User user = savedUser(1L, "alice"); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(activityLogService.getLatestLog(user)).thenThrow(new BusinessException(ErrorType.ACTIVITY_LOG_NOT_FOUND)); + + assertThatThrownBy(() -> userQueryService.findByUsername("alice")) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException) exception).getErrorType()) + .isEqualTo(ErrorType.ACTIVITY_LOG_NOT_FOUND); + + verifyNoInteractions(businessMetrics); + } } diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java index 515bca1..d178139 100644 --- a/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java @@ -30,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -107,4 +108,28 @@ void refreshesUserWhenCooldownHasExpired() { verify(userPersistenceService).updateUserStatisticsWithLog(1L, totalStats, baselineStats); verify(businessMetrics).incrementRefreshes(); } + + @Test + @DisplayName("갱신 후 최신 로그를 찾지 못하면 예외를 전파하지만 refresh metric 증가는 이미 반영된다") + void propagatesLatestLogLookupFailureAfterRefresh() { + User user = savedUser(1L, "alice"); + ReflectionTestUtils.setField(user, "lastFullScanAt", LocalDateTime.of(2020, 1, 1, 0, 0)); + GitHubAllActivitiesResponse rawResponse = GitHubAllActivitiesResponse.empty(); + ActivityStatistics totalStats = stats(30, 4, 5, 2, 7); + User updatedUser = savedUser(1L, "alice"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(gitHubActivityService.fetchRawAllActivities("alice", user.getGithubCreatedAt())).thenReturn(rawResponse); + when(gitHubDataMapper.toActivityStatistics(rawResponse)).thenReturn(totalStats); + when(baselineStatsCalculator.calculate(user, rawResponse)).thenReturn(null); + when(userPersistenceService.updateUserStatisticsWithLog(1L, totalStats, null)).thenReturn(updatedUser); + when(activityLogService.getLatestLog(updatedUser)).thenThrow(new BusinessException(ErrorType.ACTIVITY_LOG_NOT_FOUND)); + + assertThatThrownBy(() -> userRefreshService.refresh("alice")) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException) exception).getErrorType()) + .isEqualTo(ErrorType.ACTIVITY_LOG_NOT_FOUND); + + verify(businessMetrics).incrementRefreshes(); + } } diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java index aca0fee..8a0dab5 100644 --- a/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java @@ -105,6 +105,23 @@ void returnsExistingUserWithoutGitHubFetchWhenProfileDidNotChange() { verify(businessMetrics, never()).incrementRegistrations(); } + @Test + @DisplayName("기존 사용자의 email이 null이고 다른 정보가 같으면 profile update 없이 기존 응답을 반환한다") + void doesNotUpdateProfileWhenOnlyEmailIsNull() { + OAuthAttributes attributes = oauthAttributes("alice", null, "https://images.example.com/alice.png"); + User existingUser = savedUser(1L, "alice"); + ActivityLog latestLog = emptyActivityLog(existingUser); + + when(userRepository.findByNodeId("MDQ6VXNlcjEyMzQ1")).thenReturn(Optional.of(existingUser)); + when(activityLogService.findLatestLog(existingUser)).thenReturn(Optional.of(latestLog)); + + RegisterUserResponse response = userRegistrationService.register(attributes); + + assertThat(response.username()).isEqualTo("alice"); + verify(userPersistenceService, never()).updateProfile(any(User.class), any(String.class), any(String.class), any()); + verify(gitHubActivityService, never()).fetchRawAllActivities(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + @Test @DisplayName("기존 사용자 프로필이 바뀌었으면 업데이트 후 최신 정보로 응답한다") void updatesExistingUserProfileWhenChanged() { @@ -137,4 +154,24 @@ void updatesExistingUserProfileWhenChanged() { "alice@example.com" ); } + + @Test + @DisplayName("기존 사용자의 최신 로그가 없으면 empty log로 응답을 만든다") + void fallsBackToEmptyLogWhenExistingUserHasNoLatestLog() { + OAuthAttributes attributes = oauthAttributes("alice", "alice@example.com", "https://images.example.com/alice.png"); + User existingUser = savedUser(1L, "alice"); + + when(userRepository.findByNodeId("MDQ6VXNlcjEyMzQ1")).thenReturn(Optional.of(existingUser)); + when(activityLogService.findLatestLog(existingUser)).thenReturn(Optional.empty()); + + RegisterUserResponse response = userRegistrationService.register(attributes); + + assertThat(response.username()).isEqualTo("alice"); + assertThat(response.commitCount()).isZero(); + assertThat(response.issueCount()).isZero(); + assertThat(response.prCount()).isZero(); + assertThat(response.mergedPrCount()).isZero(); + assertThat(response.reviewCount()).isZero(); + verify(userPersistenceService, never()).updateProfile(any(User.class), any(String.class), any(String.class), any()); + } } diff --git a/src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java b/src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java new file mode 100644 index 0000000..7ca41e6 --- /dev/null +++ b/src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java @@ -0,0 +1,49 @@ +package com.gitranker.api.domain.user.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActivityStatisticsTest { + + @Test + @DisplayName("활동 통계는 문서화된 가중치로 점수를 계산한다") + void calculatesScoreWithConfiguredWeights() { + ActivityStatistics statistics = ActivityStatistics.of(3, 2, 1, 5, 4); + + assertThat(statistics.calculateScore()).isEqualTo(Score.of(72)); + } + + @Test + @DisplayName("이전 통계보다 감소한 항목은 diff가 음수가 된다") + void calculatesNegativeDiffWhenActivityDecreases() { + ActivityStatistics current = ActivityStatistics.of(10, 5, 3, 2, 1); + ActivityStatistics previous = ActivityStatistics.of(12, 3, 5, 1, 4); + + ActivityStatistics diff = current.calculateDiff(previous); + + assertThat(diff).isEqualTo(ActivityStatistics.of(-2, 2, -2, 1, -3)); + } + + @Test + @DisplayName("merge와 totalActivityCount는 모든 항목을 합산한다") + void mergesAllActivityCounts() { + ActivityStatistics merged = ActivityStatistics.of(1, 2, 3, 4, 5) + .merge(ActivityStatistics.of(5, 4, 3, 2, 1)); + + assertThat(merged).isEqualTo(ActivityStatistics.of(6, 6, 6, 6, 6)); + assertThat(merged.totalActivityCount()).isEqualTo(30); + assertThat(merged.hasActivity()).isTrue(); + } + + @Test + @DisplayName("empty 통계는 활동이 없고 문자열 표현도 고정된다") + void emptyStatisticsHasNoActivity() { + ActivityStatistics empty = ActivityStatistics.empty(); + + assertThat(empty.hasActivity()).isFalse(); + assertThat(empty.totalActivityCount()).isZero(); + assertThat(empty.toString()).isEqualTo("ActivityStatistics{commits=0, issues=0, prs=0, merged=0, reviews=0}"); + } +} diff --git a/src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java b/src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java new file mode 100644 index 0000000..40818ff --- /dev/null +++ b/src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java @@ -0,0 +1,72 @@ +package com.gitranker.api.domain.user.vo; + +import com.gitranker.api.domain.user.Tier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankInfoTest { + + @Test + @DisplayName("전체 사용자가 0명이면 초기 랭크 정보로 시작한다") + void returnsInitialRankInfoWhenTotalUserCountIsZero() { + RankInfo rankInfo = RankInfo.calculate(10, 0, 3000); + + assertThat(rankInfo).isEqualTo(RankInfo.initial()); + } + + @ParameterizedTest + @CsvSource({ + "0.5, 2000, CHALLENGER", + "5.0, 2000, MASTER", + "12.0, 2000, DIAMOND", + "25.0, 2000, EMERALD", + "45.0, 2000, PLATINUM", + "46.0, 2000, GOLD" + }) + @DisplayName("고득점 구간에서는 percentile 경계값으로 상위 티어가 결정된다") + void calculatesHighTierByPercentile(double percentile, int score, Tier expectedTier) { + assertThat(RankInfo.of(1, percentile, score).getTier()).isEqualTo(expectedTier); + } + + @ParameterizedTest + @CsvSource({ + "499, IRON", + "500, BRONZE", + "999, BRONZE", + "1000, SILVER", + "1499, SILVER", + "1500, GOLD", + "1999, GOLD" + }) + @DisplayName("고득점 최소치 미만에서는 절대 점수 기준 티어를 사용한다") + void calculatesScoreBasedTierBelowHighTierThreshold(int score, Tier expectedTier) { + assertThat(RankInfo.of(3, 99.0, score).getTier()).isEqualTo(expectedTier); + } + + @Test + @DisplayName("유효하지 않은 랭킹과 백분위는 예외를 발생시킨다") + void validatesRankingAndPercentile() { + assertThatThrownBy(() -> RankInfo.of(-1, 10.0, 500)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("-1"); + assertThatThrownBy(() -> RankInfo.of(1, 100.01, 500)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("100.01"); + } + + @Test + @DisplayName("승급 여부와 상위 퍼센트 판정 helper를 제공한다") + void exposesPromotionAndTopPercentHelpers() { + RankInfo promoted = RankInfo.of(2, 4.0, 2200); + RankInfo previous = RankInfo.of(20, 50.0, 1600); + + assertThat(promoted.isTierPromoted(previous)).isTrue(); + assertThat(promoted.isTopPercent(5.0)).isTrue(); + assertThat(promoted.toString()).contains("Rank 2").contains("MASTER"); + } +} diff --git a/src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java b/src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java new file mode 100644 index 0000000..201eb6d --- /dev/null +++ b/src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java @@ -0,0 +1,38 @@ +package com.gitranker.api.domain.user.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScoreTest { + + @Test + @DisplayName("점수는 활동별 가중치를 반영해 계산하고 포맷한다") + void calculatesWeightedScoreAndFormatsIt() { + Score score = Score.calculate(10, 2, 3, 4, 1); + + assertThat(score.getValue()).isEqualTo(57); + assertThat(score.toString()).isEqualTo("57 pts"); + } + + @Test + @DisplayName("zero와 비교 helper는 점수 비교와 차이를 반환한다") + void exposesComparisonHelpers() { + Score current = Score.of(1500); + Score previous = Score.zero(); + + assertThat(current.isHigherThan(previous)).isTrue(); + assertThat(previous.isHigherThan(current)).isFalse(); + assertThat(current.differenceFrom(previous)).isEqualTo(1500); + } + + @Test + @DisplayName("음수 점수는 허용하지 않는다") + void rejectsNegativeScore() { + assertThatThrownBy(() -> Score.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("-1"); + } +} diff --git a/src/test/java/com/gitranker/api/global/util/TimeUtilsTest.java b/src/test/java/com/gitranker/api/global/util/TimeUtilsTest.java index e1a626c..3fd8eeb 100644 --- a/src/test/java/com/gitranker/api/global/util/TimeUtilsTest.java +++ b/src/test/java/com/gitranker/api/global/util/TimeUtilsTest.java @@ -35,4 +35,11 @@ void formatsForDisplayAndHandlesNull() { assertThat(timeUtils.formatForDisplay(LocalDateTime.of(2025, 1, 1, 0, 30))).isEqualTo("09:30"); assertThat(timeUtils.formatForDisplay(null)).isEmpty(); } + + @Test + @DisplayName("UTCtoAppZone과 로그 포맷은 null 입력을 그대로 비운다") + void handlesNullInZoneConversionAndLogFormatting() { + assertThat(timeUtils.UTCtoAppZone(null)).isNull(); + assertThat(timeUtils.formatForLog(null)).isNull(); + } } diff --git a/src/test/java/com/gitranker/api/infrastructure/github/GitHubActivityServiceTest.java b/src/test/java/com/gitranker/api/infrastructure/github/GitHubActivityServiceTest.java new file mode 100644 index 0000000..1064ec6 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/GitHubActivityServiceTest.java @@ -0,0 +1,110 @@ +package com.gitranker.api.infrastructure.github; + +import com.gitranker.api.infrastructure.github.dto.GitHubActivitySummary; +import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; +import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse; +import com.gitranker.api.infrastructure.github.token.GitHubTokenPool; +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.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitHubActivityServiceTest { + + @InjectMocks + private GitHubActivityService service; + + @Mock + private GitHubGraphQLClient graphQLClient; + + @Mock + private GitHubTokenPool tokenPool; + + @Test + @DisplayName("fetchActivityForYear uses pooled token and maps GitHub totals") + void fetchesActivityForYear() { + GitHubAllActivitiesResponse response = activityResponse(3, 2, 4, 5, 6); + when(tokenPool.getToken()).thenReturn("token-a"); + when(graphQLClient.getActivitiesForYear("token-a", "alice", 2026)).thenReturn(response); + + GitHubActivitySummary summary = service.fetchActivityForYear("alice", 2026); + + assertThat(summary.commitCount()).isEqualTo(3); + assertThat(summary.issueCount()).isEqualTo(5); + assertThat(summary.prOpenedCount()).isEqualTo(4); + assertThat(summary.prMergedCount()).isEqualTo(2); + assertThat(summary.reviewCount()).isEqualTo(6); + verify(tokenPool).getToken(); + verify(graphQLClient).getActivitiesForYear("token-a", "alice", 2026); + } + + @Test + @DisplayName("fetchRawAllActivities uses pooled token and returns raw response") + void fetchesRawAllActivities() { + LocalDateTime joinedAt = LocalDateTime.of(2020, 1, 1, 0, 0); + GitHubAllActivitiesResponse response = GitHubAllActivitiesResponse.empty(); + when(tokenPool.getToken()).thenReturn("token-a"); + when(graphQLClient.getAllActivities("token-a", "alice", joinedAt)).thenReturn(response); + + GitHubAllActivitiesResponse actual = service.fetchRawAllActivities("alice", joinedAt); + + assertThat(actual).isSameAs(response); + verify(graphQLClient).getAllActivities("token-a", "alice", joinedAt); + } + + @Test + @DisplayName("fetchUserByNodeId uses pooled token and returns node lookup response") + void fetchesUserByNodeId() { + GitHubNodeUserResponse response = new GitHubNodeUserResponse( + new GitHubNodeUserResponse.Data( + new GitHubNodeUserResponse.Node("node-1", "alice", "alice@example.com", "avatar"), + null + ) + ); + when(tokenPool.getToken()).thenReturn("token-a"); + when(graphQLClient.getUserInfoByNodeId("token-a", "node-1")).thenReturn(response); + + GitHubNodeUserResponse actual = service.fetchUserByNodeId("node-1"); + + assertThat(actual).isSameAs(response); + verify(graphQLClient).getUserInfoByNodeId("token-a", "node-1"); + } + + @Test + @DisplayName("toSummary maps aggregated GitHub activity counts") + void mapsAggregatedResponseToSummary() { + GitHubAllActivitiesResponse response = activityResponse(9, 7, 5, 4, 3); + + GitHubActivitySummary summary = service.toSummary(response); + + assertThat(summary).isEqualTo(new GitHubActivitySummary(9, 5, 7, 4, 3)); + } + + private GitHubAllActivitiesResponse activityResponse( + int commits, + int mergedPrs, + int openedPrs, + int issues, + int reviews + ) { + GitHubAllActivitiesResponse.Data data = new GitHubAllActivitiesResponse.Data(); + data.setYearData( + "year2026", + new GitHubAllActivitiesResponse.YearData( + new GitHubAllActivitiesResponse.ContributionsCollection(commits, issues, openedPrs, reviews) + ) + ); + ReflectionTestUtils.setField(data, "mergedPRs", new GitHubAllActivitiesResponse.Search(mergedPrs)); + return new GitHubAllActivitiesResponse(data, null); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java b/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java new file mode 100644 index 0000000..fba073d --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java @@ -0,0 +1,153 @@ +package com.gitranker.api.infrastructure.github; + +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException; +import com.gitranker.api.global.error.exception.GitHubApiRetryableException; +import com.gitranker.api.global.error.exception.GitHubRateLimitException; +import io.netty.handler.timeout.ReadTimeoutException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClientRequestException; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class GitHubApiErrorHandlerTest { + + @Mock + private GitHubApiMetrics apiMetrics; + + @Test + @DisplayName("handleHttpStatus returns rate-limit exception with parsed reset time") + void handlesRateLimitStatusWithResetHeader() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + long resetEpoch = LocalDateTime.of(2026, 4, 16, 18, 0) + .atZone(ZoneId.of("Asia/Seoul")) + .toEpochSecond(); + ClientResponse response = ClientResponse.create(HttpStatus.TOO_MANY_REQUESTS) + .header("x-ratelimit-reset", String.valueOf(resetEpoch)) + .build(); + + RuntimeException actual = handler.handleHttpStatus(response); + + assertThat(actual).isInstanceOf(GitHubRateLimitException.class); + assertThat(((GitHubRateLimitException) actual).getResetAt()) + .isEqualTo(LocalDateTime.of(2026, 4, 16, 18, 0)); + verify(apiMetrics).recordRateLimitExceeded(); + } + + @Test + @DisplayName("handleHttpStatus falls back to about one hour when reset header is missing") + void fallsBackWhenResetHeaderMissing() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + LocalDateTime before = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + RuntimeException actual = handler.handleHttpStatus(ClientResponse.create(HttpStatus.FORBIDDEN).build()); + + LocalDateTime resetAt = ((GitHubRateLimitException) actual).getResetAt(); + assertThat(actual).isInstanceOf(GitHubRateLimitException.class); + assertThat(resetAt).isAfterOrEqualTo(before.plusMinutes(59)); + assertThat(resetAt).isBeforeOrEqualTo(before.plusMinutes(61)); + verify(apiMetrics).recordRateLimitExceeded(); + } + + @Test + @DisplayName("handleHttpStatus translates client and server failures to retryable GitHub errors") + void translatesClientAndServerErrors() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + + RuntimeException clientError = handler.handleHttpStatus(ClientResponse.create(HttpStatus.BAD_REQUEST).build()); + RuntimeException serverError = handler.handleHttpStatus(ClientResponse.create(HttpStatus.BAD_GATEWAY).build()); + + assertThat(clientError).isInstanceOf(GitHubApiRetryableException.class); + assertThat(((GitHubApiRetryableException) clientError).getErrorType()).isEqualTo(ErrorType.GITHUB_API_CLIENT_ERROR); + assertThat(serverError).isInstanceOf(GitHubApiRetryableException.class); + assertThat(((GitHubApiRetryableException) serverError).getErrorType()).isEqualTo(ErrorType.GITHUB_API_SERVER_ERROR); + verify(apiMetrics, org.mockito.Mockito.times(2)).recordFailure(); + } + + @Test + @DisplayName("handleHttpStatus returns null for successful status") + void returnsNullForSuccessfulStatus() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + + assertThat(handler.handleHttpStatus(ClientResponse.create(HttpStatus.OK).build())).isNull(); + } + + @Test + @DisplayName("handleGraphQLErrors ignores null and empty errors") + void ignoresEmptyGraphQLErrors() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + + assertThatCode(() -> handler.handleGraphQLErrors(null)).doesNotThrowAnyException(); + assertThatCode(() -> handler.handleGraphQLErrors(List.of())).doesNotThrowAnyException(); + } + + @Test + @DisplayName("handleGraphQLErrors translates unresolved user errors to non-retryable exception") + void translatesUserNotFoundGraphQLError() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + + assertThatThrownBy(() -> handler.handleGraphQLErrors(List.of("Could not resolve to a User with the login"))) + .isInstanceOf(GitHubApiNonRetryableException.class) + .extracting("errorType") + .isEqualTo(ErrorType.GITHUB_USER_NOT_FOUND); + } + + @Test + @DisplayName("handleGraphQLErrors translates other GraphQL errors to retryable exception") + void translatesPartialGraphQLError() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + + assertThatThrownBy(() -> handler.handleGraphQLErrors(List.of("some partial error"))) + .isInstanceOf(GitHubApiRetryableException.class) + .extracting("errorType") + .isEqualTo(ErrorType.GITHUB_PARTIAL_ERROR); + } + + @Test + @DisplayName("timeout-related helpers map to retryable GitHub exceptions with causes") + void mapsTimeoutAndIoExceptions() { + GitHubApiErrorHandler handler = new GitHubApiErrorHandler(ZoneId.of("Asia/Seoul"), apiMetrics); + TimeoutException timeout = new TimeoutException("slow"); + IOException ioException = new IOException("socket closed"); + WebClientRequestException requestException = new WebClientRequestException( + new IOException("network"), + HttpMethod.POST, + URI.create("https://api.github.com/graphql"), + HttpHeaders.EMPTY + ); + + GitHubApiRetryableException timeoutResult = handler.handleTimeout(timeout, Duration.ofSeconds(20)); + GitHubApiRetryableException readTimeoutResult = handler.handleReadTimeout(ReadTimeoutException.INSTANCE); + GitHubApiRetryableException ioResult = handler.handleIOException(ioException); + GitHubApiRetryableException networkResult = handler.handleNetworkError(requestException); + + assertThat(timeoutResult.getErrorType()).isEqualTo(ErrorType.GITHUB_API_TIMEOUT); + assertThat(timeoutResult.getCause()).isSameAs(timeout); + assertThat(readTimeoutResult.getErrorType()).isEqualTo(ErrorType.GITHUB_API_TIMEOUT); + assertThat(readTimeoutResult.getCause()).isSameAs(ReadTimeoutException.INSTANCE); + assertThat(ioResult.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR); + assertThat(ioResult.getCause()).isSameAs(ioException); + assertThat(networkResult.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR); + assertThat(networkResult.getCause()).isSameAs(requestException); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiMetricsTest.java b/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiMetricsTest.java new file mode 100644 index 0000000..7fc9ddd --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiMetricsTest.java @@ -0,0 +1,47 @@ +package com.gitranker.api.infrastructure.github; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitHubApiMetricsTest { + + @Test + @DisplayName("recordRateLimit updates gauges and cost counter") + void recordsRateLimitInformation() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + GitHubApiMetrics metrics = new GitHubApiMetrics(registry); + int cost = 3; + int remaining = 120; + LocalDateTime resetAt = LocalDateTime.of(2026, 4, 16, 18, 0); + + metrics.recordRateLimit(cost, remaining, resetAt); + + assertThat(metrics.getRemaining()).isEqualTo(remaining); + assertThat(metrics.getResetAtFormatted()).isEqualTo("2026-04-16T18:00"); + assertThat(registry.get("github_api_cost_total").counter().count()).isEqualTo(cost); + assertThat(registry.get("github_api_remaining").gauge().value()).isEqualTo(remaining); + } + + @Test + @DisplayName("success failure and rate-limit counters are tracked independently") + void recordsCallOutcomes() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + GitHubApiMetrics metrics = new GitHubApiMetrics(registry); + + metrics.recordSuccess(250); + metrics.recordFailure(); + metrics.recordRateLimitExceeded(); + + assertThat(registry.get("github_api_calls_total").tag("result", "success").counter().count()).isEqualTo(1.0); + assertThat(registry.get("github_api_calls_total").tag("result", "failure").counter().count()).isEqualTo(1.0); + assertThat(registry.get("github_api_calls_total").tag("result", "rate_limited").counter().count()).isEqualTo(1.0); + assertThat(registry.get("github_api_latency").timer().count()).isEqualTo(1L); + assertThat(registry.get("github_api_latency").timer().totalTime(TimeUnit.MILLISECONDS)).isEqualTo(250.0); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/GitHubDataMapperTest.java b/src/test/java/com/gitranker/api/infrastructure/github/GitHubDataMapperTest.java new file mode 100644 index 0000000..e6200b4 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/GitHubDataMapperTest.java @@ -0,0 +1,78 @@ +package com.gitranker.api.infrastructure.github; + +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitHubDataMapperTest { + + private final GitHubDataMapper mapper = new GitHubDataMapper(); + + @Test + @DisplayName("toActivityStatistics returns empty stats for null or missing data") + void returnsEmptyStatisticsForNullResponse() { + assertThat(mapper.toActivityStatistics(null)).isEqualTo(ActivityStatistics.empty()); + assertThat(mapper.toActivityStatistics(new GitHubAllActivitiesResponse(null, null))).isEqualTo(ActivityStatistics.empty()); + } + + @Test + @DisplayName("toActivityStatistics maps total counts from aggregated response") + void mapsResponseToStatistics() { + GitHubAllActivitiesResponse response = responseWithYears( + new Object[][] { { "year2025", 7, 3, 4, 5 } }, + 6, + 100, + LocalDateTime.of(2026, 4, 16, 18, 0) + ); + + ActivityStatistics statistics = mapper.toActivityStatistics(response); + + assertThat(statistics).isEqualTo(ActivityStatistics.of(7, 3, 4, 6, 5)); + } + + @Test + @DisplayName("calculateStatisticsUntilYear aggregates eligible years and ignores invalid keys") + void aggregatesStatisticsUntilTargetYear() { + GitHubAllActivitiesResponse.Data data = new GitHubAllActivitiesResponse.Data(); + data.setYearData("year2023", yearData(1, 2, 3, 4)); + data.setYearData("year2024", yearData(10, 20, 30, 40)); + data.setYearData("year2027", yearData(100, 200, 300, 400)); + data.setYearData("invalid", yearData(999, 999, 999, 999)); + + GitHubAllActivitiesResponse response = new GitHubAllActivitiesResponse(data, null); + + ActivityStatistics statistics = mapper.calculateStatisticsUntilYear(response, 2024); + + assertThat(statistics).isEqualTo(ActivityStatistics.of(11, 22, 33, 0, 44)); + } + + private GitHubAllActivitiesResponse responseWithYears( + Object[][] years, + int mergedPrCount, + int remaining, + LocalDateTime resetAt + ) { + GitHubAllActivitiesResponse.Data data = new GitHubAllActivitiesResponse.Data(); + for (Object[] year : years) { + data.setYearData( + (String) year[0], + yearData((int) year[1], (int) year[2], (int) year[3], (int) year[4]) + ); + } + ReflectionTestUtils.setField(data, "mergedPRs", new GitHubAllActivitiesResponse.Search(mergedPrCount)); + ReflectionTestUtils.setField(data, "rateLimit", new GitHubAllActivitiesResponse.RateLimit(5000, 1, remaining, resetAt)); + return new GitHubAllActivitiesResponse(data, null); + } + + private GitHubAllActivitiesResponse.YearData yearData(int commits, int issues, int prs, int reviews) { + return new GitHubAllActivitiesResponse.YearData( + new GitHubAllActivitiesResponse.ContributionsCollection(commits, issues, prs, reviews) + ); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/GitHubGraphQLClientTest.java b/src/test/java/com/gitranker/api/infrastructure/github/GitHubGraphQLClientTest.java new file mode 100644 index 0000000..64878ce --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/GitHubGraphQLClientTest.java @@ -0,0 +1,291 @@ +package com.gitranker.api.infrastructure.github; + +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.BusinessException; +import com.gitranker.api.global.error.exception.GitHubApiRetryableException; +import com.gitranker.api.global.error.exception.GitHubRateLimitException; +import com.gitranker.api.global.error.message.ConfigurationMessages; +import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; +import com.gitranker.api.infrastructure.github.dto.GitHubUserInfoResponse; +import com.gitranker.api.infrastructure.github.token.GitHubTokenPool; +import com.gitranker.api.infrastructure.github.util.GraphQLQueryBuilder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.net.URI; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitHubGraphQLClientTest { + + private static final ZoneId APP_ZONE = ZoneId.of("Asia/Seoul"); + + @Mock + private GraphQLQueryBuilder queryBuilder; + + @Mock + private GitHubApiMetrics apiMetrics; + + @Mock + private GitHubTokenPool tokenPool; + + @Mock + private GitHubApiErrorHandler errorHandler; + + @Test + @DisplayName("getUserInfo records rate-limit info and returns parsed response") + void getsUserInfo() { + LocalDateTime resetAt = LocalDateTime.of(2026, 4, 16, 18, 0); + when(queryBuilder.buildUserCreatedAtQuery("alice")).thenReturn("user-query"); + + GitHubGraphQLClient client = client(request -> Mono.just(jsonResponse(""" + { + "data": { + "rateLimit": {"limit":5000,"cost":3,"remaining":120,"resetAt":"2026-04-16T18:00:00"}, + "user": { + "id":"node-1", + "createdAt":"2020-01-01T00:00:00Z", + "login":"alice", + "avatarUrl":"avatar" + } + } + } + """))); + + GitHubUserInfoResponse response = client.getUserInfo("token-a", "alice"); + + assertThat(response.getLogin()).isEqualTo("alice"); + assertThat(response.getNodeId()).isEqualTo("node-1"); + verify(apiMetrics).recordRateLimit(3, 120, resetAt); + verify(tokenPool).updateTokenState("token-a", 120, resetAt); + } + + @Test + @DisplayName("getUserInfo throws rate-limit exception when remaining budget is below threshold") + void throwsWhenRateLimitGetsUnsafe() { + LocalDateTime resetAt = LocalDateTime.of(2026, 4, 16, 18, 0); + when(queryBuilder.buildUserCreatedAtQuery("alice")).thenReturn("user-query"); + + GitHubGraphQLClient client = client(request -> Mono.just(jsonResponse(""" + { + "data": { + "rateLimit": {"limit":5000,"cost":2,"remaining":10,"resetAt":"2026-04-16T18:00:00"}, + "user": { + "id":"node-1", + "createdAt":"2020-01-01T00:00:00Z", + "login":"alice", + "avatarUrl":"avatar" + } + } + } + """))); + + assertThatThrownBy(() -> client.getUserInfo("token-a", "alice")) + .isInstanceOf(GitHubRateLimitException.class) + .satisfies(throwable -> assertThat(((GitHubRateLimitException) throwable).getResetAt()).isEqualTo(resetAt)); + + verify(apiMetrics).recordRateLimit(2, 10, resetAt); + verify(tokenPool).updateTokenState("token-a", 10, resetAt); + verify(apiMetrics).recordRateLimitExceeded(); + } + + @Test + @DisplayName("getAllActivities merges merged-pr block and yearly contribution response") + void aggregatesAllActivities() { + int currentYear = LocalDate.now(APP_ZONE).getYear(); + LocalDateTime joinedAt = LocalDateTime.of(currentYear, 1, 2, 10, 0); + LocalDateTime mergedResetAt = LocalDateTime.of(2026, 4, 16, 18, 30); + LocalDateTime resetAt = LocalDateTime.of(2026, 4, 16, 19, 0); + when(queryBuilder.buildMergedPRBlock("alice")).thenReturn("merged-query"); + when(queryBuilder.buildYearlyContributionQuery("alice", currentYear, joinedAt)).thenReturn("year-query"); + + GitHubGraphQLClient client = client(request -> { + String body = requestBody(request); + if (body.contains("merged-query")) { + return Mono.just(jsonResponse(String.format(""" + { + "data": { + "rateLimit": {"limit":5000,"cost":1,"remaining":200,"resetAt":"%s"}, + "mergedPRs": {"issueCount":7} + } + } + """, mergedResetAt))); + } + if (body.contains("year-query")) { + return Mono.just(jsonResponse(String.format(""" + { + "data": { + "rateLimit": {"limit":5000,"cost":2,"remaining":199,"resetAt":"%s"}, + "year%d": { + "contributionsCollection": { + "totalCommitContributions":3, + "totalIssueContributions":4, + "totalPullRequestContributions":5, + "totalPullRequestReviewContributions":6 + } + } + } + } + """, resetAt, currentYear))); + } + return Mono.error(new IllegalStateException("Unexpected request body: " + body)); + }); + + GitHubAllActivitiesResponse response = client.getAllActivities("token-a", "alice", joinedAt); + + assertThat(response.getCommitCount()).isEqualTo(3); + assertThat(response.getIssueCount()).isEqualTo(4); + assertThat(response.getPRCount()).isEqualTo(5); + assertThat(response.getMergedPRCount()).isEqualTo(7); + assertThat(response.getReviewCount()).isEqualTo(6); + + ArgumentCaptor remainingCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor resetCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(apiMetrics).recordRateLimit(org.mockito.ArgumentMatchers.eq(3), remainingCaptor.capture(), resetCaptor.capture()); + verify(tokenPool).updateTokenState("token-a", remainingCaptor.getValue(), resetCaptor.getValue()); + assertThat(remainingCaptor.getValue()).isIn(199, 200); + if (remainingCaptor.getValue() == 199) { + assertThat(resetCaptor.getValue()).isEqualTo(resetAt); + } else { + assertThat(resetCaptor.getValue()).isEqualTo(mergedResetAt); + } + } + + @Test + @DisplayName("getActivitiesForYear delegates GraphQL errors to the error handler") + void delegatesGraphQLErrors() { + GitHubApiRetryableException mapped = new GitHubApiRetryableException(ErrorType.GITHUB_PARTIAL_ERROR); + when(queryBuilder.buildBatchQuery("alice", 2026)).thenReturn("batch-query"); + doThrow(mapped).when(errorHandler).handleGraphQLErrors(anyList()); + + GitHubGraphQLClient client = client(request -> Mono.just(jsonResponse(""" + { + "data": {}, + "errors": ["partial error"] + } + """))); + + assertThatThrownBy(() -> client.getActivitiesForYear("token-a", "alice", 2026)) + .isSameAs(mapped); + + verify(errorHandler).handleGraphQLErrors(anyList()); + verify(apiMetrics, never()).recordRateLimit(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("network errors are remapped through the error handler") + void remapsNetworkErrors() { + WebClientRequestException requestException = new WebClientRequestException( + new IOException("network"), + HttpMethod.POST, + URI.create("https://api.github.com/graphql"), + HttpHeaders.EMPTY + ); + GitHubApiRetryableException mapped = new GitHubApiRetryableException(ErrorType.GITHUB_API_ERROR, "network"); + when(queryBuilder.buildBatchQuery("alice", 2026)).thenReturn("batch-query"); + when(errorHandler.handleNetworkError(requestException)).thenReturn(mapped); + + GitHubGraphQLClient client = client(request -> Mono.error(requestException)); + + assertThatThrownBy(() -> client.getActivitiesForYear("token-a", "alice", 2026)) + .isSameAs(mapped); + } + + @Test + @DisplayName("unexpected client errors are wrapped with business exception") + void wrapsUnexpectedErrors() { + when(queryBuilder.buildUserCreatedAtQuery("alice")).thenReturn("user-query"); + GitHubGraphQLClient client = client(request -> Mono.error(new IllegalStateException("boom"))); + + assertThatThrownBy(() -> client.getUserInfo("token-a", "alice")) + .isInstanceOf(BusinessException.class) + .satisfies(throwable -> { + BusinessException exception = (BusinessException) throwable; + assertThat(exception.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR); + assertThat(exception.getData()).isEqualTo("boom"); + }); + } + + @Test + @DisplayName("blank access token is rejected before GraphQL execution") + void rejectsBlankAccessToken() { + GitHubGraphQLClient client = client(request -> Mono.error(new AssertionError("should not execute"))); + + assertThatThrownBy(() -> client.getUserInfo(" ", "alice")) + .isInstanceOf(IllegalStateException.class) + .hasMessage(ConfigurationMessages.GITHUB_ACCESS_TOKEN_REQUIRED); + } + + private GitHubGraphQLClient client(ExchangeFunction exchangeFunction) { + return new GitHubGraphQLClient( + "https://api.github.com/graphql", + queryBuilder, + APP_ZONE, + WebClient.builder().exchangeFunction(exchangeFunction), + apiMetrics, + tokenPool, + errorHandler + ); + } + + private ClientResponse jsonResponse(String json) { + return ClientResponse.create(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(json) + .build(); + } + + private String requestBody(ClientRequest request) { + MockClientHttpRequest httpRequest = new MockClientHttpRequest(request.method(), request.url()); + request.body().insert(httpRequest, new BodyInserter.Context() { + @Override + public List> messageWriters() { + return ExchangeStrategies.withDefaults().messageWriters(); + } + + @Override + public Optional serverRequest() { + return Optional.empty(); + } + + @Override + public java.util.Map hints() { + return Collections.emptyMap(); + } + }).block(); + return httpRequest.getBodyAsString().block(); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubActivitySummaryTest.java b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubActivitySummaryTest.java new file mode 100644 index 0000000..d166b25 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubActivitySummaryTest.java @@ -0,0 +1,20 @@ +package com.gitranker.api.infrastructure.github.dto; + +import com.gitranker.api.domain.user.vo.ActivityStatistics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitHubActivitySummaryTest { + + @Test + @DisplayName("summary converts to activity statistics with GitHub field ordering") + void convertsToActivityStatistics() { + GitHubActivitySummary summary = new GitHubActivitySummary(7, 3, 4, 2, 5); + + ActivityStatistics statistics = summary.toActivityStatistics(); + + assertThat(statistics).isEqualTo(ActivityStatistics.of(7, 2, 3, 4, 5)); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubAllActivitiesResponseTest.java b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubAllActivitiesResponseTest.java new file mode 100644 index 0000000..e767dc0 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubAllActivitiesResponseTest.java @@ -0,0 +1,89 @@ +package com.gitranker.api.infrastructure.github.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitHubAllActivitiesResponseTest { + + @Test + @DisplayName("empty response starts with zero counts and no errors") + void emptyResponseHasZeroCounts() { + GitHubAllActivitiesResponse response = GitHubAllActivitiesResponse.empty(); + + assertThat(response.hasErrors()).isFalse(); + assertThat(response.getCommitCount()).isZero(); + assertThat(response.getIssueCount()).isZero(); + assertThat(response.getPRCount()).isZero(); + assertThat(response.getMergedPRCount()).isZero(); + assertThat(response.getReviewCount()).isZero(); + } + + @Test + @DisplayName("count getters return zero when response data is missing") + void countGettersHandleMissingData() { + GitHubAllActivitiesResponse response = new GitHubAllActivitiesResponse(null, null); + + assertThat(response.getCommitCount()).isZero(); + assertThat(response.getIssueCount()).isZero(); + assertThat(response.getPRCount()).isZero(); + assertThat(response.getMergedPRCount()).isZero(); + assertThat(response.getReviewCount()).isZero(); + } + + @Test + @DisplayName("merge combines year data merged PRs and accumulated rate-limit cost") + void mergesResponses() { + GitHubAllActivitiesResponse base = response("year2025", 1, 2, 3, 4, 5, 1, 200, LocalDateTime.of(2026, 4, 16, 18, 0)); + GitHubAllActivitiesResponse other = response("year2026", 10, 20, 30, 40, 7, 2, 150, LocalDateTime.of(2026, 4, 16, 19, 0)); + + base.merge(other); + + assertThat(base.getCommitCount()).isEqualTo(11); + assertThat(base.getIssueCount()).isEqualTo(22); + assertThat(base.getPRCount()).isEqualTo(33); + assertThat(base.getReviewCount()).isEqualTo(44); + assertThat(base.getMergedPRCount()).isEqualTo(7); + assertThat(base.data().rateLimit().cost()).isEqualTo(3); + assertThat(base.data().rateLimit().remaining()).isEqualTo(150); + } + + @Test + @DisplayName("data stores only year-prefixed entries") + void storesOnlyYearPrefixedEntries() { + GitHubAllActivitiesResponse.Data data = new GitHubAllActivitiesResponse.Data(); + + data.setYearData("year2025", yearData(1, 1, 1, 1)); + data.setYearData("notYear", yearData(9, 9, 9, 9)); + + assertThat(data.getYearDataMap()).containsOnlyKeys("year2025"); + } + + private GitHubAllActivitiesResponse response( + String yearKey, + int commits, + int issues, + int prs, + int reviews, + int mergedPrs, + int cost, + int remaining, + LocalDateTime resetAt + ) { + GitHubAllActivitiesResponse.Data data = new GitHubAllActivitiesResponse.Data(); + data.setYearData(yearKey, yearData(commits, issues, prs, reviews)); + ReflectionTestUtils.setField(data, "mergedPRs", new GitHubAllActivitiesResponse.Search(mergedPrs)); + ReflectionTestUtils.setField(data, "rateLimit", new GitHubAllActivitiesResponse.RateLimit(5000, cost, remaining, resetAt)); + return new GitHubAllActivitiesResponse(data, null); + } + + private GitHubAllActivitiesResponse.YearData yearData(int commits, int issues, int prs, int reviews) { + return new GitHubAllActivitiesResponse.YearData( + new GitHubAllActivitiesResponse.ContributionsCollection(commits, issues, prs, reviews) + ); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubGraphQLRequestTest.java b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubGraphQLRequestTest.java new file mode 100644 index 0000000..a86ace3 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubGraphQLRequestTest.java @@ -0,0 +1,17 @@ +package com.gitranker.api.infrastructure.github.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitHubGraphQLRequestTest { + + @Test + @DisplayName("factory method stores GraphQL query string") + void createsRequest() { + GitHubGraphQLRequest request = GitHubGraphQLRequest.of("{ viewer { login } }"); + + assertThat(request.query()).isEqualTo("{ viewer { login } }"); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubNodeUserResponseTest.java b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubNodeUserResponseTest.java new file mode 100644 index 0000000..6b62f63 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubNodeUserResponseTest.java @@ -0,0 +1,41 @@ +package com.gitranker.api.infrastructure.github.dto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GitHubNodeUserResponseTest { + + @Test + @DisplayName("node user response exposes mapped fields when a user exists") + void exposesMappedFields() { + GitHubNodeUserResponse response = new GitHubNodeUserResponse( + new GitHubNodeUserResponse.Data( + new GitHubNodeUserResponse.Node("node-1", "alice", "alice@example.com", "avatar"), + null + ) + ); + + assertThat(response.hasUser()).isTrue(); + assertThat(response.getLogin()).isEqualTo("alice"); + assertThat(response.getEmail()).isEqualTo("alice@example.com"); + assertThat(response.getAvatarUrl()).isEqualTo("avatar"); + } + + @Test + @DisplayName("node user response returns null fields when node user is missing") + void returnsNullFieldsWhenUserMissing() { + GitHubNodeUserResponse response = new GitHubNodeUserResponse( + new GitHubNodeUserResponse.Data( + new GitHubNodeUserResponse.Node("node-1", null, "alice@example.com", "avatar"), + null + ) + ); + + assertThat(response.hasUser()).isFalse(); + assertThat(response.getLogin()).isNull(); + assertThat(response.getEmail()).isNull(); + assertThat(response.getAvatarUrl()).isNull(); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubUserInfoResponseTest.java b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubUserInfoResponseTest.java new file mode 100644 index 0000000..916b447 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/dto/GitHubUserInfoResponseTest.java @@ -0,0 +1,46 @@ +package com.gitranker.api.infrastructure.github.dto; + +import com.gitranker.api.global.error.ErrorType; +import com.gitranker.api.global.error.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GitHubUserInfoResponseTest { + + @Test + @DisplayName("user info response parses created-at and exposes profile fields") + void parsesUserInfoFields() { + GitHubUserInfoResponse response = new GitHubUserInfoResponse( + new GitHubUserInfoResponse.Data( + new GitHubUserInfoResponse.User("node-1", "2020-01-01T00:00:00Z", "alice", "avatar"), + new GitHubUserInfoResponse.RateLimit(5000, 1, 100, LocalDateTime.of(2026, 4, 16, 18, 0)) + ) + ); + + assertThat(response.getGitHubCreatedAt()).isEqualTo(LocalDateTime.of(2020, 1, 1, 0, 0)); + assertThat(response.getLogin()).isEqualTo("alice"); + assertThat(response.getAvatarUrl()).isEqualTo("avatar"); + assertThat(response.getNodeId()).isEqualTo("node-1"); + } + + @Test + @DisplayName("getNodeId throws business exception when user id is missing") + void throwsWhenNodeIdMissing() { + GitHubUserInfoResponse response = new GitHubUserInfoResponse( + new GitHubUserInfoResponse.Data( + new GitHubUserInfoResponse.User(null, "2020-01-01T00:00:00Z", "alice", "avatar"), + null + ) + ); + + assertThatThrownBy(response::getNodeId) + .isInstanceOf(BusinessException.class) + .satisfies(throwable -> + assertThat(((BusinessException) throwable).getErrorType()).isEqualTo(ErrorType.GITHUB_USER_NOT_FOUND)); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java b/src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java new file mode 100644 index 0000000..9615450 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java @@ -0,0 +1,67 @@ +package com.gitranker.api.infrastructure.github.token; + +import com.gitranker.api.global.error.exception.GitHubRateLimitExhaustedException; +import com.gitranker.api.global.error.message.ConfigurationMessages; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GitHubTokenPoolTest { + + private static final ZoneId APP_ZONE_ID = ZoneId.of("Asia/Seoul"); + + @Test + @DisplayName("blank token config is rejected") + void rejectsBlankTokenConfig() { + assertThatThrownBy(() -> new GitHubTokenPool(" ", 100, APP_ZONE_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(ConfigurationMessages.GITHUB_TOKEN_NOT_CONFIGURED); + } + + @Test + @DisplayName("config with only empty token entries is rejected") + void rejectsInvalidTokenEntries() { + assertThatThrownBy(() -> new GitHubTokenPool(" , , ", 100, APP_ZONE_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(ConfigurationMessages.GITHUB_TOKEN_INVALID); + } + + @Test + @DisplayName("getToken returns the first available token") + void returnsFirstAvailableToken() { + GitHubTokenPool pool = new GitHubTokenPool("token-a, token-b", 100, APP_ZONE_ID); + + assertThat(pool.getToken()).isEqualTo("token-a"); + } + + @Test + @DisplayName("getToken rotates to the next token when current token is below threshold") + void rotatesToNextAvailableToken() { + GitHubTokenPool pool = new GitHubTokenPool("token-a, token-b", 100, APP_ZONE_ID); + + pool.updateTokenState("token-a", 99, LocalDateTime.of(2099, 1, 1, 0, 0)); + + assertThat(pool.getToken()).isEqualTo("token-b"); + } + + @Test + @DisplayName("getToken throws exhausted exception with earliest reset time when all tokens are unavailable") + void throwsWhenAllTokensExhausted() { + GitHubTokenPool pool = new GitHubTokenPool("token-a, token-b", 100, APP_ZONE_ID); + LocalDateTime firstReset = LocalDateTime.of(2099, 1, 1, 18, 0); + LocalDateTime secondReset = LocalDateTime.of(2099, 1, 1, 19, 0); + + pool.updateTokenState("token-a", 10, firstReset); + pool.updateTokenState("token-b", 20, secondReset); + + assertThatThrownBy(pool::getToken) + .isInstanceOf(GitHubRateLimitExhaustedException.class) + .satisfies(throwable -> + assertThat(((GitHubRateLimitExhaustedException) throwable).getEarliestResetAt()).isEqualTo(firstReset)); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/token/TokenStateTest.java b/src/test/java/com/gitranker/api/infrastructure/github/token/TokenStateTest.java new file mode 100644 index 0000000..da63fa4 --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/token/TokenStateTest.java @@ -0,0 +1,48 @@ +package com.gitranker.api.infrastructure.github.token; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class TokenStateTest { + + private static final Instant FUTURE_RESET_AT = Instant.parse("2099-01-01T00:00:00Z"); + private static final Instant PAST_RESET_AT = Instant.parse("2000-01-01T00:00:00Z"); + + @Test + @DisplayName("new token state starts with full limit and is available") + void initializesWithDefaultLimit() { + TokenState state = new TokenState("token-a"); + + assertThat(state.getValue()).isEqualTo("token-a"); + assertThat(state.getRemaining()).isEqualTo(TokenState.DEFAULT_LIMIT); + assertThat(state.isAvailable(100)).isTrue(); + } + + @Test + @DisplayName("update changes remaining budget and reset time") + void updatesState() { + TokenState state = new TokenState("token-a"); + + state.update(50, FUTURE_RESET_AT); + + assertThat(state.getRemaining()).isEqualTo(50); + assertThat(state.getResetAt()).isEqualTo(FUTURE_RESET_AT); + assertThat(state.isAvailable(100)).isFalse(); + } + + @Test + @DisplayName("availability check restores default limit after reset time passes") + void restoresLimitAfterResetTime() { + TokenState state = new TokenState("token-a"); + state.update(0, PAST_RESET_AT); + + boolean available = state.isAvailable(100); + + assertThat(available).isTrue(); + assertThat(state.getRemaining()).isEqualTo(TokenState.DEFAULT_LIMIT); + } +} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/util/GraphQLQueryBuilderTest.java b/src/test/java/com/gitranker/api/infrastructure/github/util/GraphQLQueryBuilderTest.java new file mode 100644 index 0000000..289d55d --- /dev/null +++ b/src/test/java/com/gitranker/api/infrastructure/github/util/GraphQLQueryBuilderTest.java @@ -0,0 +1,71 @@ +package com.gitranker.api.infrastructure.github.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class GraphQLQueryBuilderTest { + + private final GraphQLQueryBuilder queryBuilder = new GraphQLQueryBuilder(ZoneId.of("Asia/Seoul")); + + @Test + @DisplayName("merged PR query targets author merged pull requests") + void buildsMergedPrBlock() { + String query = queryBuilder.buildMergedPRBlock("alice"); + + assertThat(query).contains("author:alice type:pr is:merged"); + assertThat(query).contains("issueCount"); + } + + @Test + @DisplayName("user created-at query requests rate-limit and profile fields") + void buildsUserCreatedAtQuery() { + String query = queryBuilder.buildUserCreatedAtQuery("alice"); + + assertThat(query).contains("user(login: \"alice\")"); + assertThat(query).contains("rateLimit"); + assertThat(query).contains("avatarUrl"); + } + + @Test + @DisplayName("yearly contribution query uses join date for the first year") + void buildsYearlyContributionQueryWithJoinDateBoundary() { + String query = queryBuilder.buildYearlyContributionQuery( + "alice", + 2020, + LocalDateTime.of(2020, 5, 10, 15, 30) + ); + + assertThat(query).contains("year2020"); + assertThat(query).contains("from: \"2020-05-10T15:30:00+09:00\""); + assertThat(query).contains("to: \"2020-12-31T23:59:59+09:00\""); + } + + @Test + @DisplayName("batch query includes merged PR block and full-year range for past years") + void buildsBatchQueryForPastYear() { + int pastYear = LocalDate.now(ZoneId.of("Asia/Seoul")).getYear() - 1; + + String query = queryBuilder.buildBatchQuery("alice", pastYear); + + assertThat(query).contains("mergedPRs: search(query: \"author:alice type:pr is:merged\""); + assertThat(query).contains("year" + pastYear); + assertThat(query).contains("from: \"" + pastYear + "-01-01T00:00:00+09:00\""); + assertThat(query).contains("to: \"" + pastYear + "-12-31T23:59:59+09:00\""); + } + + @Test + @DisplayName("node lookup query targets the given node id") + void buildsUserLookupByNodeIdQuery() { + String query = queryBuilder.buildUserLookupByNodeIdQuery("node-123"); + + assertThat(query).contains("node(id: \"node-123\")"); + assertThat(query).contains("email"); + assertThat(query).contains("avatarUrl"); + } +}