diff --git a/slackjudge/.gitignore b/slackjudge/.gitignore index 172dbf7..c3a9585 100644 --- a/slackjudge/.gitignore +++ b/slackjudge/.gitignore @@ -36,3 +36,4 @@ out/ ### VS Code ### .vscode/ .env +/logs/* \ No newline at end of file diff --git a/slackjudge/src/main/java/store/slackjudge/batch/repository/ProblemJdbcRepository.java b/slackjudge/src/main/java/store/slackjudge/batch/repository/ProblemJdbcRepository.java index 6220ec3..1bae0e0 100644 --- a/slackjudge/src/main/java/store/slackjudge/batch/repository/ProblemJdbcRepository.java +++ b/slackjudge/src/main/java/store/slackjudge/batch/repository/ProblemJdbcRepository.java @@ -26,7 +26,7 @@ public class ProblemJdbcRepository { * @date 25. 12. 8. * ==========================**/ - public void updateProblemSolved(LocalDateTime batchTime, Long userId, Integer problemNumber){ + public void updateProblemSolved(LocalDateTime batchTime, Long userId, Integer problemNumber) { String sql = """ INSERT INTO users_problem (user_id, problem_id, is_solved, solved_time) VALUES (?, ?, true, ?) @@ -41,16 +41,16 @@ ON CONFLICT (user_id, problem_id) public void batchInsertProblems(LocalDateTime snapshotAt, Long userId, List problemIds) { String sql = """ - INSERT INTO users_problem (user_id, problem_id, is_solved, solved_time) - VALUES (?, ?, true, ?) - ON CONFLICT (user_id, problem_id) DO NOTHING - """; + INSERT INTO users_problem (user_id, problem_id, is_solved, solved_time) + VALUES (?, ?, true, ?) + ON CONFLICT (user_id, problem_id) DO NOTHING + """; - jdbcTemplate.batchUpdate(sql, problemIds, problemIds.size(), - (ps, problemId) -> { - ps.setLong(1, userId); - ps.setInt(2, problemId); - ps.setTimestamp(3, java.sql.Timestamp.valueOf(snapshotAt)); - }); + jdbcTemplate.batchUpdate(sql, problemIds, problemIds.size(), + (ps, problemId) -> { + ps.setLong(1, userId); + ps.setInt(2, problemId); + ps.setTimestamp(3, java.sql.Timestamp.valueOf(snapshotAt)); + }); } } diff --git a/slackjudge/src/test/java/store/slackjudge/batch/infra/mongo/document/SnapShotIdTest.java b/slackjudge/src/test/java/store/slackjudge/batch/infra/mongo/document/SnapShotIdTest.java new file mode 100644 index 0000000..908e717 --- /dev/null +++ b/slackjudge/src/test/java/store/slackjudge/batch/infra/mongo/document/SnapShotIdTest.java @@ -0,0 +1,101 @@ +package store.slackjudge.batch.infra.mongo.document; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class SnapShotIdTest { + + @Test + @DisplayName("of()는 동일한 값(bojId, snapShotAt)을 가진 인스턴스를 생성") + void of_createsInstance() { + LocalDateTime t = LocalDateTime.of(2025, 12, 16, 10, 0); + SnapShotId id = SnapShotId.of("test", t); + + assertThat(id.getBojId()).isEqualTo("test"); + assertThat(id.getSnapShotAt()).isEqualTo(t); + } + + @Test + @DisplayName("같은 필드 값을 가지면 equals는 true이고 hashCode도 같다") + void equalsAndHashCode_sameValues() { + LocalDateTime t = LocalDateTime.of(2025, 12, 16, 10, 0); + + SnapShotId a = SnapShotId.of("boj", t); + SnapShotId b = SnapShotId.of("boj", t); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("bojId가 다르면 equals는 false") + void equals_false_whenDifferentBojId() { + LocalDateTime t = LocalDateTime.of(2025, 12, 16, 10, 0); + + SnapShotId a = SnapShotId.of("bojA", t); + SnapShotId b = SnapShotId.of("bojB", t); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("snapShotAt이 다르면 equals는 false") + void equals_false_whenDifferentSnapShotAt() { + LocalDateTime t1 = LocalDateTime.of(2025, 12, 16, 10, 0); + LocalDateTime t2 = LocalDateTime.of(2025, 12, 16, 11, 0); + + SnapShotId a = SnapShotId.of("boj", t1); + SnapShotId b = SnapShotId.of("boj", t2); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("null 또는 다른 타입과는 equals가 false") + void equals_false_whenNullOrDifferentType() { + SnapShotId a = SnapShotId.of("boj", LocalDateTime.of(2025, 12, 16, 10, 0)); + + assertThat(a.equals(null)).isFalse(); + assertThat(a.equals("not-a-snapshot-id")).isFalse(); + } + + @Test + @DisplayName("HashSet에서 동일 키는 중복 저장되지 않는다") + void hashSet_deduplicatesByEqualsAndHashCode() { + LocalDateTime t = LocalDateTime.of(2025, 12, 16, 10, 0); + + SnapShotId a = SnapShotId.of("boj", t); + SnapShotId b = SnapShotId.of("boj", t); + + Set set = new HashSet<>(); + set.add(a); + set.add(b); + + assertThat(set).hasSize(1); + assertThat(set).contains(a); + } + + @Test + @DisplayName("HashMap 키로 사용 시 동일 키로 조회가 가능") + void hashMap_keyLookupWorks() { + LocalDateTime t = LocalDateTime.of(2025, 12, 16, 10, 0); + + SnapShotId key1 = SnapShotId.of("boj", t); + SnapShotId key2 = SnapShotId.of("boj", t); // equals true + + Map map = new HashMap<>(); + map.put(key1, "value"); + + assertThat(map.get(key2)).isEqualTo("value"); + } + +} \ No newline at end of file diff --git a/slackjudge/src/test/java/store/slackjudge/batch/repository/ProblemJdbcRepositoryTest.java b/slackjudge/src/test/java/store/slackjudge/batch/repository/ProblemJdbcRepositoryTest.java index 375a1f7..e4247de 100644 --- a/slackjudge/src/test/java/store/slackjudge/batch/repository/ProblemJdbcRepositoryTest.java +++ b/slackjudge/src/test/java/store/slackjudge/batch/repository/ProblemJdbcRepositoryTest.java @@ -15,9 +15,11 @@ import store.slackjudge.batch.PostgresTestContainer; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; + @ActiveProfiles("test") @EnablePostgresTest @DataJpaTest @@ -155,4 +157,56 @@ INSERT INTO users_problem (user_id, problem_id, is_solved, solved_time) assertThat(usersProblem.userId).isEqualTo(userId); } + @DisplayName("여러 문제를 batch로 INSERT(중복은 무시)") + @Test + void batchInsertProblems() { + // given + Long userId = 12L; + LocalDateTime snapshotAt = LocalDateTime.of(2025, 1, 1, 10, 0); + List problemIds = List.of(1000, 1001); + + // 기존 데이터 하나 삽입(중복 검증용) + jdbcTemplate.update(""" + INSERT INTO users_problem (user_id, problem_id, is_solved, solved_time) + VALUES (?, ?, true, ?) + """, userId, 1000, LocalDateTime.of(2000, 1, 1, 0, 0)); + + // when + repository.batchInsertProblems(snapshotAt, userId, problemIds); + + // then + String sql = """ + SELECT user_id, problem_id, is_solved, solved_time + FROM users_problem + WHERE user_id = ? + ORDER BY problem_id + """; + + List results = jdbcTemplate.query( + sql, + (rs, rowNum) -> new UsersProblem( + rs.getLong("user_id"), + rs.getInt("problem_id"), + rs.getBoolean("is_solved"), + rs.getTimestamp("solved_time").toLocalDateTime() + ), + userId + ); + + // problem_id = 1000,1001 총 2개 + assertThat(results).hasSize(2); + + // 1000번 문제는 기존 데이터 유지 (ON CONFLICT DO NOTHING) + UsersProblem first = results.get(0); + assertThat(first.problemId()).isEqualTo(1000); + assertThat(first.solvedTime()) + .isEqualTo(LocalDateTime.of(2000, 1, 1, 0, 0)); + + // 1001번 문제는 새로 INSERT + UsersProblem second = results.get(1); + assertThat(second.problemId()).isEqualTo(1001); + assertThat(second.solvedTime()).isEqualTo(snapshotAt); + assertThat(second.isSolved()).isTrue(); + } + } \ No newline at end of file diff --git a/slackjudge/src/test/java/store/slackjudge/batch/tasklet/DetectAndUpdateUserTierAndProblemTaskletTest.java b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/DetectAndUpdateUserTierAndProblemTaskletTest.java new file mode 100644 index 0000000..0d1f5f2 --- /dev/null +++ b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/DetectAndUpdateUserTierAndProblemTaskletTest.java @@ -0,0 +1,225 @@ +package store.slackjudge.batch.tasklet; + +import org.junit.jupiter.api.BeforeEach; +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.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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.retry.support.RetryTemplate; +import store.slackjudge.batch.common.CalculateSnapShotDate; +import store.slackjudge.batch.config.BatchLogger; +import store.slackjudge.batch.dto.UserInfo; +import store.slackjudge.batch.infra.mongo.document.SnapShotId; +import store.slackjudge.batch.infra.mongo.document.UserSolvedSnapShotDocument; +import store.slackjudge.batch.infra.mongo.dto.SaveSnapshot; +import store.slackjudge.batch.service.DetectionContext; +import store.slackjudge.batch.service.ProblemChangeDetector; +import store.slackjudge.batch.service.TierChangeDetector; +import store.slackjudge.batch.infra.solvedac.client.SolvedAcProblemInfoClient; +import store.slackjudge.batch.infra.solvedac.dto.UserInfoResponse; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DetectAndUpdateUserTierAndProblemTaskletTest { + + @Mock + private ProblemChangeDetector problemChangeDetector; + @Mock + private TierChangeDetector tierChangeDetector; + @Mock + private SolvedAcProblemInfoClient problemInfoClient; + @Mock + private CalculateSnapShotDate calculateSnapShotDate; + @Mock + private BatchLogger logger; + @Mock + private StepContribution stepContribution; + + private ChunkContext chunkContext; + private StepExecution stepExecution; + private JobExecution jobExecution; + + private ExecutionContext jobContext; + + private RetryTemplate retryTemplate = new RetryTemplate(); + + private DetectAndUpdateUserTierAndProblemTasklet tasklet; + + private final LocalDateTime BATCH_TIME = + LocalDateTime.of(2025, 12, 16, 10, 0); + private final LocalDateTime SNAPSHOT_AT = + LocalDateTime.of(2025, 12, 16, 9, 0); + + @BeforeEach + void setUp() { + tasklet = new DetectAndUpdateUserTierAndProblemTasklet( + problemChangeDetector, + tierChangeDetector, + problemInfoClient, + calculateSnapShotDate, + logger, + retryTemplate, + BATCH_TIME + ); + + jobExecution = new JobExecution(1L); + jobContext = jobExecution.getExecutionContext(); + stepExecution = new StepExecution("step", jobExecution); + + StepContext stepContext = new StepContext(stepExecution); + chunkContext = new ChunkContext(stepContext); + } + + @Test + @DisplayName("사용자 목록이 비어있으면 즉시 종료") + void execute_noUsers() throws Exception { + jobContext.put("users", Collections.emptyList()); + + RepeatStatus result = tasklet.execute(stepContribution, chunkContext); + + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + verify(logger).stepEnd( + eq("DetectAndUpdateUserTierAndProblemTasklet"), + eq("No users found") + ); + verifyNoInteractions(problemChangeDetector, tierChangeDetector, problemInfoClient); + } + + @Test + @DisplayName("신규 유저 처리 성공") + void execute_newUser() throws Exception { + //given + when(calculateSnapShotDate.currentHour(BATCH_TIME)) + .thenReturn(SNAPSHOT_AT); + + UserInfo user = new UserInfo("boj1", 1L, 1); + UserInfoResponse response = + new UserInfoResponse(10, "boj1", 3, 100); + + jobContext.put("users", List.of(user)); + jobContext.put("snapshots", Collections.emptyMap()); + jobContext.put("userSolvedInfo", Map.of("boj1", response)); + + when(problemInfoClient.fetchAllProblems("boj1")) + .thenReturn(List.of(1, 2, 3)); + + //when + RepeatStatus result = tasklet.execute(stepContribution, chunkContext); + + //then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + + verify(problemChangeDetector).saveNewProblem( + eq(SNAPSHOT_AT), + eq(1L), + any() + ); + } + + @Test + @DisplayName("기존 유저 - 티어 변경") + void execute_existingUser_tierChange() throws Exception { + //given + UserInfo user = new UserInfo("boj1", 1L, 5); + UserInfoResponse response = + new UserInfoResponse(5, "boj1", 4, 200); + + UserSolvedSnapShotDocument prev = + UserSolvedSnapShotDocument.builder() + .id(SnapShotId.of("boj1", SNAPSHOT_AT)) + .tier(3) + .solvedCount(5) + .solvedProblemIds(Set.of(1, 2)) + .build(); + + jobContext.put("users", List.of(user)); + jobContext.put("snapshots", Map.of("boj1", prev)); + jobContext.put("userSolvedInfo", Map.of("boj1", response)); + + when(tierChangeDetector.detect(any())).thenReturn(true); + + //when + RepeatStatus result = tasklet.execute(stepContribution, chunkContext); + + //then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + assertThat(jobContext.getInt("UPDATED_USERS")).isEqualTo(1); + + verify(tierChangeDetector).update(any()); + verify(problemInfoClient, never()).fetchAllProblems(anyString()); + } + + @Test + @DisplayName("기존 유저 - 문제 변경") + void execute_existingUser_problemChange() throws Exception { + //given + UserInfo user = new UserInfo("boj1", 1L, 5); + UserInfoResponse response = + new UserInfoResponse(6, "boj1", 3, 200); + + UserSolvedSnapShotDocument prev = + UserSolvedSnapShotDocument.builder() + .id(SnapShotId.of("boj1", SNAPSHOT_AT)) + .tier(3) + .solvedCount(5) + .solvedProblemIds(Set.of(1, 2)) + .build(); + + jobContext.put("users", List.of(user)); + jobContext.put("snapshots", Map.of("boj1", prev)); + jobContext.put("userSolvedInfo", Map.of("boj1", response)); + + when(problemInfoClient.fetchAllProblems("boj1")) + .thenReturn(List.of(1, 2, 3)); + when(problemChangeDetector.detect(any())).thenReturn(true); + + //when + RepeatStatus result = tasklet.execute(stepContribution, chunkContext); + + //then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + assertThat(jobContext.getInt("UPDATED_USERS")).isEqualTo(1); + + verify(problemChangeDetector).update(any()); + } + + @Test + @DisplayName("solved.ac 정보 없는 유저 스킵") + void execute_skipUser() throws Exception { + //given + UserInfo user = new UserInfo("fail", 1L, 1); + + jobContext.put("users", List.of(user)); + jobContext.put("userSolvedInfo", Collections.emptyMap()); + + //when + RepeatStatus result = tasklet.execute(stepContribution, chunkContext); + + //then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + assertThat(jobContext.getInt("FAILED_USERS")).isEqualTo(1); + + verify(logger) + .taskletWarn("Skipped user due to no solved.ac info: fail"); + } +} \ No newline at end of file diff --git a/slackjudge/src/test/java/store/slackjudge/batch/tasklet/FetchSolvedAcUserInfoTaskletTest.java b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/FetchSolvedAcUserInfoTaskletTest.java new file mode 100644 index 0000000..764c9cd --- /dev/null +++ b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/FetchSolvedAcUserInfoTaskletTest.java @@ -0,0 +1,206 @@ +package store.slackjudge.batch.tasklet; + +import org.junit.jupiter.api.BeforeEach; +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.batch.core.JobExecution; +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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.support.RetryTemplate; +import store.slackjudge.batch.config.BatchLogger; +import store.slackjudge.batch.dto.UserInfo; +import store.slackjudge.batch.infra.solvedac.client.SolvedAcUserInfoClient; +import store.slackjudge.batch.infra.solvedac.dto.UserInfoResponse; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FetchSolvedAcUserInfoTaskletTest { + + @Mock private BatchLogger logger; + @Mock private SolvedAcUserInfoClient userInfoClient; + @Mock private RetryTemplate retryTemplate; + + @Mock private StepContribution contribution; + @Mock private ChunkContext chunkContext; + @Mock private StepContext stepContext; + @Mock private StepExecution stepExecution; + @Mock private JobExecution jobExecution; + @Mock private ExecutionContext jobContext; + @Mock private ExecutionContext stepExecutionContext; + + private FetchSolvedAcUserInfoTasklet tasklet; + + @BeforeEach + void setUp() { + when(chunkContext.getStepContext()).thenReturn(stepContext); + when(stepContext.getStepExecution()).thenReturn(stepExecution); + when(stepExecution.getJobExecution()).thenReturn(jobExecution); + when(jobExecution.getExecutionContext()).thenReturn(jobContext); + + tasklet = new FetchSolvedAcUserInfoTasklet( + logger, + userInfoClient, + retryTemplate + ); + } + + @Test + @DisplayName("모든 유저의 solved.ac 정보를 조회 -> ExecutionContext에 저장") + void shouldFetchAllUserInfoAndSaveToContext() throws Exception { + // given + when(stepExecution.getExecutionContext()).thenReturn(stepExecutionContext); + List users = List.of( + new UserInfo("boj1", 1L, 1), + new UserInfo("boj2", 2L, 2) + ); + when(jobContext.get("users")).thenReturn(users); + + UserInfoResponse response1 = createMockResponse("boj1", 1, 100, 1000); + UserInfoResponse response2 = createMockResponse("boj2", 2, 200, 1500); + + when(retryTemplate.execute(any(RetryCallback.class))).thenAnswer(invocation -> + invocation.getArgument(0, RetryCallback.class).doWithRetry(null) + ); + + when(userInfoClient.call("boj1", 0)).thenReturn(response1); + when(userInfoClient.call("boj2", 0)).thenReturn(response2); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(stepExecutionContext).put(eq("userSolvedInfo"), captor.capture()); + + Map savedMap = captor.getValue(); + assertThat(savedMap).hasSize(2); + assertThat(savedMap.get("boj1")).isEqualTo(response1); + assertThat(savedMap.get("boj2")).isEqualTo(response2); + + verify(logger).stepStart("FetchSolvedAcUserInfoTasklet"); + verify(logger).stepEnd(eq("FetchSolvedAcUserInfoTasklet"), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("유저가 없으면 조기 종료") + void shouldFinishEarlyWhenNoUsers() throws Exception { + // given + when(jobContext.get("users")).thenReturn(null); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(userInfoClient, never()).call(anyString(), anyInt()); + verify(logger).stepEnd("FetchSolvedAcUserInfoTasklet", "No users found"); + } + + @Test + @DisplayName("빈 유저 리스트일 때 종료") + void shouldFinishEarlyWhenEmptyUsers() throws Exception { + // given + when(jobContext.get("users")).thenReturn(Collections.emptyList()); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(userInfoClient, never()).call(anyString(), anyInt()); + verify(logger).stepEnd("FetchSolvedAcUserInfoTasklet", "No users found"); + } + + @Test + @DisplayName("API 호출 실패 시 해당 유저는 스킵 -> 다른 유저는 계속 처리") + void shouldSkipFailedUserAndContinue() throws Exception { + // given + when(stepExecution.getExecutionContext()).thenReturn(stepExecutionContext); + List users = List.of( + new UserInfo("boj1", 1L, 1), + new UserInfo("boj2", 2L, 2), + new UserInfo("boj3", 3L, 3) + ); + when(jobContext.get("users")).thenReturn(users); + + UserInfoResponse response1 = createMockResponse("boj1", 1, 100, 1000); + UserInfoResponse response3 = createMockResponse("boj3", 3, 300, 2000); + + when(retryTemplate.execute(any(RetryCallback.class))).thenAnswer(invocation -> + invocation.getArgument(0, RetryCallback.class).doWithRetry(null) + ); + + when(userInfoClient.call("boj1", 0)).thenReturn(response1); + when(userInfoClient.call("boj2", 0)).thenThrow(new RuntimeException("Network Error")); + when(userInfoClient.call("boj3", 0)).thenReturn(response3); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(stepExecutionContext).put(eq("userSolvedInfo"), captor.capture()); + + Map savedMap = captor.getValue(); + assertThat(savedMap).hasSize(2); + assertThat(savedMap.get("boj1")).isEqualTo(response1); + assertThat(savedMap.get("boj3")).isEqualTo(response3); + assertThat(savedMap).doesNotContainKey("boj2"); + + verify(logger).userWarn(eq(2L), eq("boj2"), anyString()); + } + + @Test + @DisplayName("모든 유저의 API 호출이 실패해도 정상 종료") + void shouldFinishNormallyEvenWhenAllFailed() throws Exception { + // given + when(stepExecution.getExecutionContext()).thenReturn(stepExecutionContext); + List users = List.of( + new UserInfo("boj1", 1L, 1) + ); + when(jobContext.get("users")).thenReturn(users); + + when(retryTemplate.execute(any(RetryCallback.class))).thenAnswer(invocation -> + invocation.getArgument(0, RetryCallback.class).doWithRetry(null) + ); + + when(userInfoClient.call("boj1", 0)) + .thenThrow(new RuntimeException("Network Error")); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(stepExecutionContext).put(eq("userSolvedInfo"), captor.capture()); + + Map savedMap = captor.getValue(); + assertThat(savedMap).isEmpty(); + } + + private UserInfoResponse createMockResponse(String handle, int tier, int solvedCount, int rating) { + return new UserInfoResponse(solvedCount, handle, tier, rating); + } +} diff --git a/slackjudge/src/test/java/store/slackjudge/batch/tasklet/LoadAllUsersTaskletTest.java b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/LoadAllUsersTaskletTest.java new file mode 100644 index 0000000..2ef5ea4 --- /dev/null +++ b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/LoadAllUsersTaskletTest.java @@ -0,0 +1,80 @@ +package store.slackjudge.batch.tasklet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import store.slackjudge.batch.config.BatchLogger; +import store.slackjudge.batch.repository.UserJdbcRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import store.slackjudge.batch.dto.UserInfo; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadAllUsersTaskletTest { + + @Mock + private BatchLogger logger; + + @Mock + private UserJdbcRepository repository; + + @Mock + private StepContribution contribution; + + private LoadAllUsersTasklet tasklet; + + private StepExecution stepExecution; + private ChunkContext chunkContext; + + @BeforeEach + void setUp() { + tasklet = new LoadAllUsersTasklet(repository, logger); + + stepExecution = new StepExecution("LoadAllUsersStep", null); + StepContext stepContext = new StepContext(stepExecution); + chunkContext = new ChunkContext(stepContext); + } + + @Test + @DisplayName("모든 유저 정보를 조회하고 ExecutionContext에 저장") + void execute_success() { + // given + List users = List.of( + new UserInfo("boj1", 5L, 100), + new UserInfo("boj2", 10L, 300) + ); + + when(repository.findAllUserInfo()).thenReturn(users); + + // when + RepeatStatus result = tasklet.execute(contribution, chunkContext); + + // then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + + ExecutionContext executionContext = stepExecution.getExecutionContext(); + assertThat(executionContext.containsKey("users")).isTrue(); + assertThat((List) executionContext.get("users")) + .hasSize(2) + .isEqualTo(users); + + verify(repository, times(1)).findAllUserInfo(); + verify(logger, times(1)).stepStart("LoadAllUsersTasklet"); + verify(logger, times(1)) + .stepEnd(eq("LoadAllUsersTasklet"), + eq("usersSize=2"), + startsWith("duration=")); + } +} diff --git a/slackjudge/src/test/java/store/slackjudge/batch/tasklet/LoadSnapshotTaskletTest.java b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/LoadSnapshotTaskletTest.java new file mode 100644 index 0000000..667b505 --- /dev/null +++ b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/LoadSnapshotTaskletTest.java @@ -0,0 +1,166 @@ +package store.slackjudge.batch.tasklet; + +import org.junit.jupiter.api.BeforeEach; +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.batch.core.JobExecution; +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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import store.slackjudge.batch.common.CalculateSnapShotDate; +import store.slackjudge.batch.config.BatchLogger; +import store.slackjudge.batch.dto.UserInfo; +import store.slackjudge.batch.infra.mongo.document.UserSolvedSnapShotDocument; +import store.slackjudge.batch.infra.mongo.repository.UserSolvedSnapShotRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadSnapshotTaskletTest { + + @Mock + private UserSolvedSnapShotRepository repository; + + @Mock + private CalculateSnapShotDate snapShotDate; + + @Mock + private BatchLogger logger; + + @Mock + private StepContribution contribution; + + @Mock + private ChunkContext chunkContext; + + @Mock + private StepExecution stepExecution; + + @Mock + private JobExecution jobExecution; + + @Mock + private ExecutionContext stepContext; + + @Mock + private ExecutionContext jobContext; + + private LoadSnapshotTasklet tasklet; + + private LocalDateTime batchTime = LocalDateTime.of(2024, 12, 15, 10, 0); + + @BeforeEach + void setUp() { + tasklet = new LoadSnapshotTasklet( + repository, + snapShotDate, + logger, + batchTime + ); + + // Mock 체인 설정 + StepContext stepContext = mock(StepContext.class); + when(chunkContext.getStepContext()).thenReturn(stepContext); + when(stepContext.getStepExecution()).thenReturn(stepExecution); + when(stepExecution.getJobExecution()).thenReturn(jobExecution); + when(jobExecution.getExecutionContext()).thenReturn(jobContext); + } + + @Test + @DisplayName("유저 스냅샷을 조회하고 ExecutionContext에 저장") + void shouldLoadSnapshotsAndSaveToContext() throws Exception { + // given + LocalDateTime snapshotAt = LocalDateTime.of(2024, 12, 15, 9, 0); + when(snapShotDate.snapshotDate(batchTime)).thenReturn(snapshotAt); + when(stepExecution.getExecutionContext()).thenReturn(stepContext); + + List users = List.of( + new UserInfo("user1", 1L, 1), + new UserInfo("user2", 2L, 2) + ); + when(jobContext.get("users")).thenReturn(users); + + UserSolvedSnapShotDocument doc1 = createMockDocument("user1", snapshotAt); + UserSolvedSnapShotDocument doc2 = createMockDocument("user2", snapshotAt); + + when(repository.findByIdBojIdAndIdSnapShotAt("user1", snapshotAt)) + .thenReturn(Optional.of(doc1)); + when(repository.findByIdBojIdAndIdSnapShotAt("user2", snapshotAt)) + .thenReturn(Optional.of(doc2)); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(Map.class); + verify(stepContext).put(eq("snapshots"), captor.capture()); + + Map snapshots = (Map) captor.getValue(); + assertThat(snapshots).hasSize(2); + assertThat(snapshots.get("user1")).isEqualTo(doc1); + assertThat(snapshots.get("user2")).isEqualTo(doc2); + } + + @Test + @DisplayName("스냅샷이 없는 유저는 null로 처리") + void shouldHandleMissingSnapshot() throws Exception { + // given + LocalDateTime snapshotAt = LocalDateTime.of(2024, 12, 15, 9, 0); + when(snapShotDate.snapshotDate(batchTime)).thenReturn(snapshotAt); + when(stepExecution.getExecutionContext()).thenReturn(stepContext); + + List users = List.of(new UserInfo("user1", 1L, 1)); + when(jobContext.get("users")).thenReturn(users); + + when(repository.findByIdBojIdAndIdSnapShotAt("user1", snapshotAt)) + .thenReturn(Optional.empty()); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Map.class); + verify(stepContext).put(eq("snapshots"), captor.capture()); + + Map snapshots = captor.getValue(); + assertThat(snapshots.get("user1")).isNull(); + } + + @Test + @DisplayName("유저가 없으면 조기 종료") + void shouldFinishEarlyWhenNoUsers() throws Exception { + // given + when(jobContext.get("users")).thenReturn(null); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(repository, never()).findByIdBojIdAndIdSnapShotAt(any(), any()); + verify(logger).stepEnd(eq("LoadSnapshotTasklet"), eq("No users found")); + } + + // Mock 문서 생성 + private UserSolvedSnapShotDocument createMockDocument(String bojId, LocalDateTime snapShotAt) { + return new UserSolvedSnapShotDocument(/* ... */); + } +} \ No newline at end of file diff --git a/slackjudge/src/test/java/store/slackjudge/batch/tasklet/SaveSnapshotTaskletTest.java b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/SaveSnapshotTaskletTest.java new file mode 100644 index 0000000..5d77cf7 --- /dev/null +++ b/slackjudge/src/test/java/store/slackjudge/batch/tasklet/SaveSnapshotTaskletTest.java @@ -0,0 +1,203 @@ +package store.slackjudge.batch.tasklet; + +import org.junit.jupiter.api.BeforeEach; +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.batch.core.JobExecution; +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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import store.slackjudge.batch.config.BatchLogger; +import store.slackjudge.batch.infra.mongo.document.UserSolvedSnapShotDocument; +import store.slackjudge.batch.infra.mongo.dto.SaveSnapshot; +import store.slackjudge.batch.infra.mongo.service.UserSnapShotService; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SaveSnapshotTaskletTest { + + @Mock + private UserSnapShotService service; + + @Mock + private BatchLogger logger; + + @Mock + private StepContribution contribution; + + @Mock + private ChunkContext chunkContext; + + @Mock + private StepExecution stepExecution; + + @Mock + private JobExecution jobExecution; + + @Mock + private ExecutionContext jobContext; + + private SaveSnapshotTasklet tasklet; + + @BeforeEach + void setUp() { + tasklet = new SaveSnapshotTasklet(service, logger); + + // Mock 체인 설정 + StepContext mockStepContext = mock(StepContext.class); + when(chunkContext.getStepContext()).thenReturn(mockStepContext); + when(mockStepContext.getStepExecution()).thenReturn(stepExecution); + when(stepExecution.getJobExecution()).thenReturn(jobExecution); + when(jobExecution.getExecutionContext()).thenReturn(jobContext); + } + + @Test + @DisplayName("모든 스냅샷을 저장") + void shouldSaveAllSnapshots() { + // given + LocalDateTime snapshotAt = LocalDateTime.of(2024, 12, 15, 10, 0); + + SaveSnapshot snapshot1 = new SaveSnapshot( + "user1", + snapshotAt, + Set.of(1000, 1001), + 2, + 5, + 1L, + 1200 + ); + + SaveSnapshot snapshot2 = new SaveSnapshot( + "user2", + snapshotAt, + Set.of(2000, 2001, 2002), + 3, + 4, + 2L, + 1500 + ); + + Map currentSnapshots = new HashMap<>(); + currentSnapshots.put("user1", snapshot1); + currentSnapshots.put("user2", snapshot2); + + when(jobContext.get("currentSnapshot")).thenReturn(currentSnapshots); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + + // 각 스냅샷이 저장되었는지 검증 + verify(service).saveSnapshot(snapshot1); + verify(service).saveSnapshot(snapshot2); + verify(service, times(2)).saveSnapshot(any(SaveSnapshot.class)); + + // Logger 호출 검증 + verify(logger).stepStart("SaveSnapshotTasklet"); + verify(logger).stepEnd(eq("SaveSnapshotTasklet"), any(), any()); + } + + @Test + @DisplayName("스냅샷이 null이면 조기 종료") + void shouldFinishEarlyWhenSnapshotsIsNull() { + // given + when(jobContext.get("currentSnapshot")).thenReturn(null); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(service, never()).saveSnapshot(any()); + verify(logger).stepEnd("SaveSnapshotTasklet", "No current snapshots"); + } + + @Test + @DisplayName("스냅샷이 비어있으면 조기 종료") + void shouldFinishEarlyWhenSnapshotsIsEmpty() { + // given + when(jobContext.get("currentSnapshot")).thenReturn(Collections.emptyMap()); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(service, never()).saveSnapshot(any()); + verify(logger).stepEnd("SaveSnapshotTasklet", "No current snapshots"); + } + + @Test + @DisplayName("단일 스냅샷도 정상적으로 저장") + void shouldSaveSingleSnapshot() { + // given + LocalDateTime snapshotAt = LocalDateTime.of(2024, 12, 15, 10, 0); + + SaveSnapshot snapshot = new SaveSnapshot( + "user1", + snapshotAt, + Set.of(1000, 1001), + 2, + 5, + 1L, + 1200 + ); + + Map currentSnapshots = Map.of("user1", snapshot); + when(jobContext.get("currentSnapshot")).thenReturn(currentSnapshots); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(service).saveSnapshot(snapshot); + verify(service, times(1)).saveSnapshot(any(SaveSnapshot.class)); + } + + @Test + @DisplayName("빈 문제 세트를 가진 스냅샷도 저장") + void shouldSaveSnapshotWithEmptyProblemSet() { + // given + LocalDateTime snapshotAt = LocalDateTime.of(2024, 12, 15, 10, 0); + + SaveSnapshot snapshot = new SaveSnapshot( + "newUser", + snapshotAt, + Collections.emptySet(), + 0, + 5, + 1L, + 0 + ); + + Map currentSnapshots = Map.of("newUser", snapshot); + when(jobContext.get("currentSnapshot")).thenReturn(currentSnapshots); + + // when + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // then + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + verify(service).saveSnapshot(snapshot); + } +} \ No newline at end of file