From 7306ff4ecee917a3c9157419302ce4dfc516ea78 Mon Sep 17 00:00:00 2001 From: hyoseok Date: Mon, 20 Jul 2026 18:20:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20stale=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9E=AC=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=99=80=20Grafana=20=EA=B3=B5=EA=B0=9C=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - git-ranker-api에 image 태그 명시(${DOCKERHUB_USERNAME}/git-ranker:${IMAGE_TAG:-latest})해 CI push 이미지와 compose pull 연결 (P0-4) - deploy.yml SSH 스크립트에 set -euo pipefail 추가해 pull/cd 실패 시 즉시 중단 (P0-4) - grafana ports를 127.0.0.1 loopback 바인딩으로 변경해 인터넷 노출 차단 (P0-5) - DOCKERHUB_USERNAME·DB_PASSWORD·GF_SECURITY_ADMIN_PASSWORD에 ${VAR:?} 강제로 빈 값 폴백 방지 (P0-5) - 인프라 설정 변경이라 테스트 harness 없음: 검증은 docker compose config로 image 해석 확인, 배포 드라이런은 사람 게이트 G4 --- .github/workflows/deploy.yml | 2 ++ docker-compose.yml | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 393e185..79b74fb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -89,6 +89,8 @@ jobs: username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | + set -euo pipefail + echo "==========================================" echo "Backend Deployment Started" echo "Time: $(date)" diff --git a/docker-compose.yml b/docker-compose.yml index 9d19c73..ab521db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: git-ranker-api: + image: ${DOCKERHUB_USERNAME:?DOCKERHUB_USERNAME is required}/git-ranker:${IMAGE_TAG:-latest} build: . container_name: git-ranker-api restart: unless-stopped @@ -10,7 +11,7 @@ services: SPRING_PROFILES_ACTIVE: prod DB_URL: jdbc:mysql://git-ranker-db:3306/${DB_NAME}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} + DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} GITHUB_REDIRECT_URI: ${GITHUB_REDIRECT_URI} @@ -45,7 +46,7 @@ services: environment: MYSQL_DATABASE: ${DB_NAME} MYSQL_USER: ${DB_USERNAME} - MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} TZ: Asia/Seoul ports: @@ -135,7 +136,7 @@ services: container_name: git-ranker-grafana restart: unless-stopped ports: - - "3001:3000" + - "127.0.0.1:3001:3000" volumes: - ./datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml - grafana_data:/var/lib/grafana @@ -144,7 +145,7 @@ services: - ./alerting:/etc/grafana/provisioning/alerting environment: GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER} - GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:?GF_SECURITY_ADMIN_PASSWORD is required} DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} GF_SERVER_DOMAIN: ${GF_SERVER_DOMAIN} GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL} From 5adc1961ea2459ea56552efaf4647acba3a48c90 Mon Sep 17 00:00:00 2001 From: hyoseok Date: Mon, 20 Jul 2026 18:30:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=9C=20node=5Fid=20unique=20=EC=9C=84=EB=B0=98?= =?UTF-8?q?=EC=9D=84=20findByNodeId=20=ED=8F=B4=EB=B0=B1=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=ED=9D=A1=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - register의 신규 등록 경로에서 DataIntegrityViolationException을 잡아 findByNodeId 재조회로 기존 사용자 처리에 재진입 - 동시 refresh/로그인 race로 중복 삽입이 시도돼도 멱등하게 기존 사용자 응답 반환 - UserRegistrationServiceTest에 unique 위반 폴백 케이스 추가 (P0-2b) --- .../user/service/UserRegistrationService.java | 13 +++++++- .../service/UserRegistrationServiceTest.java | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gitranker/api/domain/user/service/UserRegistrationService.java b/src/main/java/com/gitranker/api/domain/user/service/UserRegistrationService.java index 06c4c87..782ec06 100644 --- a/src/main/java/com/gitranker/api/domain/user/service/UserRegistrationService.java +++ b/src/main/java/com/gitranker/api/domain/user/service/UserRegistrationService.java @@ -16,6 +16,7 @@ import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import java.time.LocalDate; @@ -38,7 +39,17 @@ public RegisterUserResponse register(OAuthAttributes attributes) { Optional existingUser = userRepository.findByNodeId(attributes.nodeId()); return existingUser.map(user -> handleExistingUser(user, attributes)) - .orElseGet(() -> handleNewUser(attributes)); + .orElseGet(() -> registerNewUserOrFallback(attributes)); + } + + private RegisterUserResponse registerNewUserOrFallback(OAuthAttributes attributes) { + try { + return handleNewUser(attributes); + } catch (DataIntegrityViolationException e) { + User concurrentlyCreatedUser = userRepository.findByNodeId(attributes.nodeId()) + .orElseThrow(() -> e); + return handleExistingUser(concurrentlyCreatedUser, attributes); + } } private RegisterUserResponse handleNewUser(OAuthAttributes attributes) { 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 8a0dab5..e14a918 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 @@ -17,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import java.util.Optional; @@ -29,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any; 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; @@ -155,6 +157,35 @@ void updatesExistingUserProfileWhenChanged() { ); } + @Test + @DisplayName("동시 등록으로 node_id unique 위반이 나면 findByNodeId 폴백으로 기존 사용자 응답을 만든다") + void fallsBackToExistingUserWhenConcurrentRegistrationViolatesUnique() { + OAuthAttributes attributes = oauthAttributes("alice", "alice@example.com", "https://images.example.com/alice.png"); + GitHubAllActivitiesResponse rawResponse = GitHubAllActivitiesResponse.empty(); + ActivityStatistics totalStats = stats(10, 2, 3, 1, 4); + ActivityStatistics baselineStats = stats(5, 1, 1, 1, 2); + User concurrentlyCreatedUser = savedUser(1L, "alice"); + ActivityLog latestLog = emptyActivityLog(concurrentlyCreatedUser); + + when(userRepository.findByNodeId("MDQ6VXNlcjEyMzQ1")) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(concurrentlyCreatedUser)); + when(gitHubActivityService.fetchRawAllActivities("alice", attributes.githubCreatedAt())).thenReturn(rawResponse); + when(gitHubDataMapper.toActivityStatistics(rawResponse)).thenReturn(totalStats); + when(baselineStatsCalculator.calculate(any(User.class), eq(rawResponse))).thenReturn(baselineStats); + when(userPersistenceService.saveNewUser(any(User.class), eq(totalStats), eq(baselineStats))) + .thenThrow(new DataIntegrityViolationException("Duplicate entry for key 'users.node_id'")); + when(activityLogService.findLatestLog(concurrentlyCreatedUser)).thenReturn(Optional.of(latestLog)); + + RegisterUserResponse response = userRegistrationService.register(attributes); + + assertThat(response.username()).isEqualTo("alice"); + assertThat(response.isNewUser()).isFalse(); + assertThat(response.userId()).isEqualTo(1L); + verify(userRepository, times(2)).findByNodeId("MDQ6VXNlcjEyMzQ1"); + verify(businessMetrics, never()).incrementRegistrations(); + } + @Test @DisplayName("기존 사용자의 최신 로그가 없으면 empty log로 응답을 만든다") void fallsBackToEmptyLogWhenExistingUserHasNoLatestLog() { From 51bb928009c3e915da0f42f17f6ec3ce600e5ced Mon Sep 17 00:00:00 2001 From: hyoseok Date: Mon, 20 Jul 2026 18:36:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=84=B8=EC=84=9C=20=EC=88=9C=EC=88=98=ED=99=94?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20DB=20=EC=93=B0=EA=B8=B0=EB=A5=BC=20writer?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=B4=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=A6=9D=ED=8F=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScoredUserUpdate carrier 신설: 프로세서 계산 결과(user/stats/diff/date)를 writer로 전달 - ScoreRecalculationProcessor를 User→ScoredUserUpdate 순수 함수로 변경, saveActivityLog 제거해 청크 tx에서 DB 쓰기 분리 - UserItemWriter가 청크 tx 안에서 user saveAll + 활동 로그 find-or-update(upsert) 수행해 점수·로그 원자적 커밋 - 스텝에 processorNonTransactional 추가해 롤백·재시도 시 GitHub 재fetch 없이 캐시된 프로세서 출력 재사용 - rate-limit 예외(GitHubRateLimitException/Exhausted)를 noRetry로 지정해 무의미한 백오프 반복 차단 (skip은 유지) - 프로세서/writer 테스트를 carrier 기준으로 재작성; job config 재시도 설정은 배치 설정이라 단위 테스트 harness 없음 (P0-3) --- .../api/batch/dto/ScoredUserUpdate.java | 18 +++++ .../job/DailyScoreRecalculationJobConfig.java | 8 ++- .../ScoreRecalculationProcessor.java | 14 ++-- .../api/batch/writer/UserItemWriter.java | 28 ++++++-- .../ScoreRecalculationProcessorTest.java | 64 ++++++----------- .../api/batch/writer/UserItemWriterTest.java | 71 +++++++++++++++++-- 6 files changed, 143 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/gitranker/api/batch/dto/ScoredUserUpdate.java diff --git a/src/main/java/com/gitranker/api/batch/dto/ScoredUserUpdate.java b/src/main/java/com/gitranker/api/batch/dto/ScoredUserUpdate.java new file mode 100644 index 0000000..402aefe --- /dev/null +++ b/src/main/java/com/gitranker/api/batch/dto/ScoredUserUpdate.java @@ -0,0 +1,18 @@ +package com.gitranker.api.batch.dto; + +import com.gitranker.api.domain.user.User; +import com.gitranker.api.domain.user.vo.ActivityStatistics; + +import java.time.LocalDate; + +/** + * 프로세서가 계산한 점수/활동 통계를 writer로 넘기는 carrier. + * DB 쓰기는 writer가 청크 트랜잭션 안에서 수행하고, 프로세서는 순수 계산 결과만 담는다. + */ +public record ScoredUserUpdate( + User user, + ActivityStatistics stats, + ActivityStatistics diff, + LocalDate date +) { +} diff --git a/src/main/java/com/gitranker/api/batch/job/DailyScoreRecalculationJobConfig.java b/src/main/java/com/gitranker/api/batch/job/DailyScoreRecalculationJobConfig.java index ccca17c..4ce57a0 100644 --- a/src/main/java/com/gitranker/api/batch/job/DailyScoreRecalculationJobConfig.java +++ b/src/main/java/com/gitranker/api/batch/job/DailyScoreRecalculationJobConfig.java @@ -3,6 +3,7 @@ import com.gitranker.api.batch.listener.BatchProgressListener; import com.gitranker.api.batch.listener.GitHubCostListener; import com.gitranker.api.batch.listener.UserScoreCalculationSkipListener; +import com.gitranker.api.batch.dto.ScoredUserUpdate; import com.gitranker.api.batch.processor.ScoreRecalculationProcessor; import com.gitranker.api.batch.reader.UserItemReader; import com.gitranker.api.batch.tasklet.RankingRecalculationTasklet; @@ -10,6 +11,8 @@ import com.gitranker.api.domain.user.User; 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 com.gitranker.api.global.error.exception.GitHubRateLimitExhaustedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; @@ -53,12 +56,15 @@ public Job dailyScoreRecalculationJob() { @Bean public Step scoreRecalculationStep() { return new StepBuilder("scoreRecalculationStep", jobRepository) - .chunk(chunkSize, transactionManager) + .chunk(chunkSize, transactionManager) .reader(userItemReader.createReader(chunkSize)) .processor(scoreRecalculationProcessor) .writer(userItemWriter) .faultTolerant() + .processorNonTransactional() .retry(GitHubApiRetryableException.class) + .noRetry(GitHubRateLimitException.class) + .noRetry(GitHubRateLimitExhaustedException.class) .retryLimit(3) .backOffPolicy(new ExponentialBackOffPolicy()) .skip(GitHubApiNonRetryableException.class) diff --git a/src/main/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessor.java b/src/main/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessor.java index fe061c2..b45c54e 100644 --- a/src/main/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessor.java +++ b/src/main/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessor.java @@ -1,12 +1,12 @@ package com.gitranker.api.batch.processor; +import com.gitranker.api.batch.dto.ScoredUserUpdate; import com.gitranker.api.batch.strategy.ActivityUpdateContext; import com.gitranker.api.batch.strategy.ActivityUpdateStrategy; 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.domain.user.vo.Score; @@ -27,16 +27,15 @@ @Slf4j @Component @RequiredArgsConstructor -public class ScoreRecalculationProcessor implements ItemProcessor { +public class ScoreRecalculationProcessor implements ItemProcessor { private final ActivityLogRepository activityLogRepository; - private final ActivityLogService activityLogService; private final IncrementalActivityUpdateStrategy incrementalStrategy; private final FullActivityUpdateStrategy fullStrategy; private final GitHubActivityService gitHubActivityService; @Override - public User process(User user) { + public ScoredUserUpdate process(User user) { try { return recalculateScore(user); } catch (GitHubApiNonRetryableException e) { @@ -51,7 +50,7 @@ public User process(User user) { } } - private User recalculateScore(User user) { + private ScoredUserUpdate recalculateScore(User user) { int oldScore = user.getTotalScore(); int currentYear = LocalDate.now().getYear(); @@ -62,15 +61,14 @@ private User recalculateScore(User user) { user.updateScore(newScore); ActivityStatistics diffStats = updateStats.calculateDiff(previousStats); - activityLogService.saveActivityLog(user, updateStats, diffStats, LocalDate.now()); log.debug("점수 갱신 완료 - 사용자: {}, 변동: {}", LogSanitizer.maskUsername(user.getUsername()), newScore.differenceFrom(Score.of(oldScore))); - return user; + return new ScoredUserUpdate(user, updateStats, diffStats, LocalDate.now()); } - private User handleUsernameChanged(User user) { + private ScoredUserUpdate handleUsernameChanged(User user) { String oldUsername = user.getUsername(); GitHubNodeUserResponse response = gitHubActivityService.fetchUserByNodeId(user.getNodeId()); diff --git a/src/main/java/com/gitranker/api/batch/writer/UserItemWriter.java b/src/main/java/com/gitranker/api/batch/writer/UserItemWriter.java index e2f29af..8d8ad0a 100644 --- a/src/main/java/com/gitranker/api/batch/writer/UserItemWriter.java +++ b/src/main/java/com/gitranker/api/batch/writer/UserItemWriter.java @@ -1,5 +1,7 @@ package com.gitranker.api.batch.writer; +import com.gitranker.api.batch.dto.ScoredUserUpdate; +import com.gitranker.api.domain.log.ActivityLogService; import com.gitranker.api.domain.user.User; import com.gitranker.api.domain.user.UserRepository; import com.gitranker.api.global.error.exception.BusinessException; @@ -10,22 +12,40 @@ import org.springframework.batch.item.ItemWriter; import org.springframework.stereotype.Component; +import java.util.List; + @Slf4j @Component @RequiredArgsConstructor -public class UserItemWriter implements ItemWriter { +public class UserItemWriter implements ItemWriter { private final UserRepository userRepository; + private final ActivityLogService activityLogService; @Override - public void write(Chunk chunk) throws Exception { + public void write(Chunk chunk) throws Exception { try { - userRepository.saveAll(chunk.getItems()); + List users = chunk.getItems().stream() + .map(ScoredUserUpdate::user) + .toList(); + userRepository.saveAll(users); + + for (ScoredUserUpdate update : chunk.getItems()) { + upsertActivityLog(update); + } - log.debug("배치 Chunk 저장 완료 - Size: {}", chunk.getItems().size()); + log.debug("배치 Chunk 저장 완료 - Size: {}", users.size()); } catch (Exception e) { throw new BusinessException(ErrorType.BATCH_STEP_FAILED, "DB 저장 실패"); } } + + private void upsertActivityLog(ScoredUserUpdate update) { + activityLogService.findByDate(update.user(), update.date()) + .ifPresentOrElse( + existingLog -> activityLogService.updateActivityLog(existingLog, update.stats(), update.diff()), + () -> activityLogService.saveActivityLog(update.user(), update.stats(), update.diff(), update.date()) + ); + } } diff --git a/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java b/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java index 331e3ad..57e1854 100644 --- a/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java +++ b/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java @@ -1,11 +1,11 @@ package com.gitranker.api.batch.processor; +import com.gitranker.api.batch.dto.ScoredUserUpdate; 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; @@ -30,7 +30,6 @@ 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; @@ -46,9 +45,6 @@ class ScoreRecalculationProcessorTest { @Mock private ActivityLogRepository activityLogRepository; - @Mock - private ActivityLogService activityLogService; - @Mock private IncrementalActivityUpdateStrategy incrementalStrategy; @@ -59,7 +55,7 @@ class ScoreRecalculationProcessorTest { private GitHubActivityService gitHubActivityService; @Test - @DisplayName("processor uses incremental strategy when a baseline log exists") + @DisplayName("baseline 로그가 있으면 incremental 전략으로 계산한 payload를 반환한다") void usesIncrementalStrategyWhenBaselineExists() { User user = TestFixtures.user("alice"); LocalDate today = LocalDate.now(); @@ -85,9 +81,12 @@ void usesIncrementalStrategyWhenBaselineExists() { )).thenReturn(Optional.of(baselineLog)); when(incrementalStrategy.update(eq(user), any(ActivityUpdateContext.class))).thenReturn(updatedStats); - User processed = processor.process(user); + ScoredUserUpdate result = processor.process(user); - assertThat(processed).isSameAs(user); + assertThat(result.user()).isSameAs(user); + assertThat(result.stats()).isEqualTo(updatedStats); + assertThat(result.diff()).isEqualTo(updatedStats.calculateDiff(latestLog.toStatistics())); + assertThat(result.date()).isEqualTo(today); assertThat(user.getTotalScore()).isEqualTo(updatedStats.calculateScore().getValue()); verify(fullStrategy, never()).update(any(), any()); @@ -95,16 +94,10 @@ void usesIncrementalStrategyWhenBaselineExists() { 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") + @DisplayName("이전 로그가 없으면 full 전략으로 계산하고 empty diff payload를 반환한다") void usesFullStrategyWhenNoBaselineExists() { User user = TestFixtures.user("alice"); LocalDate today = LocalDate.now(); @@ -119,9 +112,12 @@ void usesFullStrategyWhenNoBaselineExists() { )).thenReturn(Optional.empty()); when(fullStrategy.update(eq(user), any(ActivityUpdateContext.class))).thenReturn(updatedStats); - User processed = processor.process(user); + ScoredUserUpdate result = processor.process(user); - assertThat(processed).isSameAs(user); + assertThat(result.user()).isSameAs(user); + assertThat(result.stats()).isEqualTo(updatedStats); + assertThat(result.diff()).isEqualTo(updatedStats.calculateDiff(ActivityStatistics.empty())); + assertThat(result.date()).isEqualTo(today); assertThat(user.getTotalScore()).isEqualTo(updatedStats.calculateScore().getValue()); verify(incrementalStrategy, never()).update(any(), any()); @@ -129,17 +125,10 @@ void usesFullStrategyWhenNoBaselineExists() { 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") + @DisplayName("username이 바뀌면 node id로 프로필을 갱신하고 재시도한 payload를 반환한다") void refreshesProfileAndRetriesWhenUsernameChanged() { User user = TestFixtures.user("old-name"); ActivityStatistics updatedStats = TestFixtures.stats(9, 1, 2, 3, 4); @@ -160,9 +149,12 @@ void refreshesProfileAndRetriesWhenUsernameChanged() { .thenReturn(updatedStats); when(gitHubActivityService.fetchUserByNodeId(user.getNodeId())).thenReturn(response); - User processed = processor.process(user); + ScoredUserUpdate result = processor.process(user); - assertThat(processed).isSameAs(user); + assertThat(result.user()).isSameAs(user); + assertThat(result.stats()).isEqualTo(updatedStats); + assertThat(result.diff()).isEqualTo(updatedStats); + assertThat(result.date()).isEqualTo(LocalDate.now()); assertThat(user.getUsername()).isEqualTo("new-name"); assertThat(user.getEmail()).isEqualTo("new@example.com"); assertThat(user.getProfileImage()).isEqualTo("https://images.example.com/new.png"); @@ -170,16 +162,10 @@ void refreshesProfileAndRetriesWhenUsernameChanged() { 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") + @DisplayName("node 조회로 대체 사용자를 찾지 못하면 user-not-found를 유지한다") void rethrowsUserNotFoundWhenNodeLookupFails() { User user = TestFixtures.user("alice"); GitHubNodeUserResponse response = new GitHubNodeUserResponse( @@ -199,12 +185,10 @@ void rethrowsUserNotFoundWhenNodeLookupFails() { .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") + @DisplayName("retryable GitHub 오류는 감싸지 않고 그대로 전파한다") void propagatesRetryableGitHubError() { User user = TestFixtures.user("alice"); GitHubApiRetryableException exception = new GitHubApiRetryableException(ErrorType.GITHUB_API_TIMEOUT); @@ -221,7 +205,7 @@ void propagatesRetryableGitHubError() { } @Test - @DisplayName("processor propagates non-retryable GitHub errors other than username-changed") + @DisplayName("username-changed 외 non-retryable GitHub 오류는 그대로 전파한다") void propagatesNonRetryableGitHubError() { User user = TestFixtures.user("alice"); GitHubApiNonRetryableException exception = @@ -239,7 +223,7 @@ void propagatesNonRetryableGitHubError() { } @Test - @DisplayName("processor wraps unexpected failures with batch-step business exception") + @DisplayName("예상치 못한 실패는 batch-step business 예외로 감싼다") void wrapsUnexpectedFailure() { User user = TestFixtures.user("alice"); @@ -258,7 +242,5 @@ void wrapsUnexpectedFailure() { 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/writer/UserItemWriterTest.java b/src/test/java/com/gitranker/api/batch/writer/UserItemWriterTest.java index 9877e57..6b8c6bd 100644 --- a/src/test/java/com/gitranker/api/batch/writer/UserItemWriterTest.java +++ b/src/test/java/com/gitranker/api/batch/writer/UserItemWriterTest.java @@ -1,7 +1,11 @@ package com.gitranker.api.batch.writer; +import com.gitranker.api.batch.dto.ScoredUserUpdate; +import com.gitranker.api.domain.log.ActivityLog; +import com.gitranker.api.domain.log.ActivityLogService; import com.gitranker.api.domain.user.User; import com.gitranker.api.domain.user.UserRepository; +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.support.TestFixtures; @@ -13,10 +17,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.batch.item.Chunk; +import java.time.LocalDate; 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.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,24 +39,73 @@ class UserItemWriterTest { @Mock private UserRepository userRepository; + @Mock + private ActivityLogService activityLogService; + + private ScoredUserUpdate update(User user, ActivityStatistics stats, ActivityStatistics diff, LocalDate date) { + return new ScoredUserUpdate(user, stats, diff, date); + } + @Test - @DisplayName("writer saves all users in the chunk") + @DisplayName("청크의 모든 사용자를 저장한다") void savesChunkUsers() throws Exception { + LocalDate today = LocalDate.now(); User first = TestFixtures.user("alice"); User second = TestFixtures.user("bob"); - Chunk chunk = new Chunk<>(List.of(first, second)); + Chunk chunk = new Chunk<>(List.of( + update(first, TestFixtures.stats(1, 1, 1, 1, 1), ActivityStatistics.empty(), today), + update(second, TestFixtures.stats(2, 2, 2, 2, 2), ActivityStatistics.empty(), today) + )); + + userItemWriter.write(chunk); + + verify(userRepository).saveAll(List.of(first, second)); + } + + @Test + @DisplayName("해당 일자 로그가 없으면 활동 로그를 새로 저장한다") + void savesActivityLogWhenAbsent() throws Exception { + LocalDate today = LocalDate.now(); + User user = TestFixtures.user("alice"); + ActivityStatistics stats = TestFixtures.stats(5, 2, 1, 3, 4); + ActivityStatistics diff = TestFixtures.stats(1, 0, 0, 1, 0); + Chunk chunk = new Chunk<>(List.of(update(user, stats, diff, today))); + + when(activityLogService.findByDate(user, today)).thenReturn(Optional.empty()); + + userItemWriter.write(chunk); + + verify(activityLogService).saveActivityLog(user, stats, diff, today); + verify(activityLogService, never()).updateActivityLog(any(), any(), any()); + } + + @Test + @DisplayName("해당 일자 로그가 있으면 새로 저장하지 않고 기존 로그를 갱신한다") + void updatesActivityLogWhenPresent() throws Exception { + LocalDate today = LocalDate.now(); + User user = TestFixtures.user("alice"); + ActivityStatistics stats = TestFixtures.stats(5, 2, 1, 3, 4); + ActivityStatistics diff = TestFixtures.stats(1, 0, 0, 1, 0); + ActivityLog existingLog = ActivityLog.empty(user, today); + Chunk chunk = new Chunk<>(List.of(update(user, stats, diff, today))); + + when(activityLogService.findByDate(user, today)).thenReturn(Optional.of(existingLog)); userItemWriter.write(chunk); - verify(userRepository).saveAll(chunk.getItems()); + verify(activityLogService).updateActivityLog(existingLog, stats, diff); + verify(activityLogService, never()).saveActivityLog(any(), any(), any(), eq(today)); } @Test - @DisplayName("writer wraps repository failures with batch-step exception") + @DisplayName("리포지토리 실패는 batch-step 예외로 감싼다") void wrapsRepositoryFailure() { - Chunk chunk = new Chunk<>(List.of(TestFixtures.user("alice"))); + LocalDate today = LocalDate.now(); + Chunk chunk = new Chunk<>(List.of( + update(TestFixtures.user("alice"), TestFixtures.stats(1, 1, 1, 1, 1), ActivityStatistics.empty(), today) + )); - when(userRepository.saveAll(chunk.getItems())).thenThrow(new RuntimeException("db down")); + when(userRepository.saveAll(anyList())).thenThrow(new RuntimeException("db down")); assertThatThrownBy(() -> userItemWriter.write(chunk)) .isInstanceOf(BusinessException.class)