Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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"
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
alexization marked this conversation as resolved.
}
Loading
Loading