diff --git a/backend/src/main/java/com/stackup/stackup/common/retry/RetryPolicy.java b/backend/src/main/java/com/stackup/stackup/common/retry/RetryPolicy.java new file mode 100644 index 00000000..38f70586 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/retry/RetryPolicy.java @@ -0,0 +1,67 @@ +package com.stackup.stackup.common.retry; + +import java.util.Arrays; + +public record RetryPolicy(long[] backoffMillis) { + + public RetryPolicy { + if (backoffMillis == null) { + throw new IllegalArgumentException("backoffMillis must not be null"); + } + for (long delay : backoffMillis) { + if (delay < 0) { + throw new IllegalArgumentException("backoffMillis must be non-negative, got: " + delay); + } + } + backoffMillis = backoffMillis.clone(); + } + + public static RetryPolicy of(long... backoffMillis) { + return new RetryPolicy(backoffMillis); + } + + public static RetryPolicy exponentialBackoff(long initialDelayMillis, int maxAttempts) { + if (initialDelayMillis < 0) { + throw new IllegalArgumentException("initialDelayMillis must be non-negative, got: " + initialDelayMillis); + } + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1, got: " + maxAttempts); + } + if (maxAttempts > 50) { + throw new IllegalArgumentException("maxAttempts must be at most 50 to avoid overflow, got: " + maxAttempts); + } + long[] backoffMillis = new long[maxAttempts - 1]; + for (int i = 0; i < backoffMillis.length; i++) { + backoffMillis[i] = initialDelayMillis * (1L << i); + } + return new RetryPolicy(backoffMillis); + } + + public int maxAttempts() { + return backoffMillis.length + 1; + } + + public long backoffForAttempt(int attempt) { + return backoffMillis[attempt - 1]; + } + + @Override + public long[] backoffMillis() { + return backoffMillis.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof RetryPolicy that && Arrays.equals(backoffMillis, that.backoffMillis); + } + + @Override + public int hashCode() { + return Arrays.hashCode(backoffMillis); + } + + @Override + public String toString() { + return "RetryPolicy" + Arrays.toString(backoffMillis); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/retry/RetryingExecutor.java b/backend/src/main/java/com/stackup/stackup/common/retry/RetryingExecutor.java new file mode 100644 index 00000000..5a28a0ad --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/retry/RetryingExecutor.java @@ -0,0 +1,85 @@ +package com.stackup.stackup.common.retry; + +import java.util.function.Supplier; + +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class RetryingExecutor { + + private static final Logger log = LoggerFactory.getLogger(RetryingExecutor.class); + + public void execute(String operationName, RetryPolicy policy, Runnable action) { + execute(operationName, policy, () -> { + action.run(); + return null; + }); + } + + public T execute(String operationName, RetryPolicy policy, Supplier action) { + try { + return runWithRetry(operationName, policy, action); + } catch (RetryInterruptedException interrupted) { + log.error("Interrupted while retrying operation={}", operationName, interrupted); + return null; + } catch (RuntimeException terminalFailure) { + log.error("Exhausted retries for operation={} (attempts={})", + operationName, policy.maxAttempts(), terminalFailure); + return null; + } + } + + public T executeOrThrow(String operationName, RetryPolicy policy, Supplier action) { + return runWithRetry(operationName, policy, action); + } + + private T runWithRetry(String operationName, @NonNull RetryPolicy policy, Supplier action) { + int maxAttempts = policy.maxAttempts(); + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + T result = action.get(); + if (attempt > 1) { + log.info("Succeeded operation={} on retry attempt={}", operationName, attempt); + } + return result; + } catch (RuntimeException failure) { + lastFailure = failure; + if (attempt == maxAttempts) { + break; + } + long backoff = policy.backoffForAttempt(attempt); + log.warn("Operation failed name={} attempt={}/{}; retrying in {}ms", + operationName, attempt, maxAttempts, backoff, failure); + if (!sleep(backoff)) { + throw new RetryInterruptedException(operationName, failure); + } + } + } + throw lastFailure; + } + + // millis 만큼 sleep합니다 + // 테스트 코드에서 이 메서드를 오버라이드하여 sleep을 건너뛸 수 있습니다 + protected boolean sleep(long millis) { + if (millis <= 0) { + return true; + } + try { + Thread.sleep(millis); + return true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + + public static final class RetryInterruptedException extends RuntimeException { + public RetryInterruptedException(String operationName, Throwable cause) { + super("Interrupted while retrying operation=" + operationName, cause); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java new file mode 100644 index 00000000..7d45105b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java @@ -0,0 +1,114 @@ +package com.stackup.stackup.github.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.application.dto.CandidateRepositoryListResult; +import com.stackup.stackup.github.application.dto.CandidateRepositoryResult; +import com.stackup.stackup.github.application.dto.GithubRepositoryResult; +import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand; +import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.github.infrastructure.GithubApiClient; +import com.stackup.stackup.github.infrastructure.GithubTokenCipher; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoListPage; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GithubRepositoryService { + + private final GithubRepositoryRepository repoRepository; + private final UserRepository userRepository; + private final GithubApiClient githubApiClient; + private final GithubTokenCipher tokenCipher; + private final ApplicationEventPublisher events; + + + public Page list(Long userId, Pageable pageable) { + return repoRepository.findByUser_IdAndDeletedFalse(userId, pageable) + .map(GithubRepositoryResult::from); + } + + public GithubRepositoryResult get(Long userId, Long repositoryId) { + return repoRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId) + .map(GithubRepositoryResult::from) + .orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND)); + } + + @Transactional + public void delete(Long userId, Long repositoryId) { + GithubRepository repo = repoRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND)); + repo.softDelete(); + } + + @Transactional + public GithubRepositoryResult register(Long userId, RegisterRepositoryCommand command) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + String plainToken = tokenCipher.decrypt(user.getEncryptedGithubAccessToken()); + GithubRepoResponse meta = githubApiClient.getRepository(plainToken, command.githubRepoId()); + + GithubRepository repo = repoRepository.findByUser_IdAndGithubRepoId(userId, command.githubRepoId()) + .map(existing -> { + if (!existing.isDeleted()) { + throw new DomainException(ApiErrorCode.REPO_ALREADY_REGISTERED); + } + existing.resurrect(meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch()); + return existing; + }) + .orElseGet(() -> repoRepository.save(GithubRepository.create( + user, + meta.id(), meta.name(), meta.fullName(), meta.htmlUrl(), meta.defaultBranch() + ))); + + String sealedToken = tokenCipher.encrypt(plainToken); + events.publishEvent(new RepositoryRegisteredEvent( + repo.getId(), userId, repo.getRepoFullName(), repo.getDefaultBranch(), sealedToken + )); + + return GithubRepositoryResult.from(repo); + } + + public CandidateRepositoryListResult listCandidates(Long userId, int page, int perPage) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + String token = tokenCipher.decrypt(user.getEncryptedGithubAccessToken()); + GithubRepoListPage listPage = githubApiClient.listUserRepositories(token, page, perPage); + + Set githubIds = listPage.repos().stream() + .map(GithubRepoResponse::id) + .collect(Collectors.toSet()); + + Set registered = githubIds.isEmpty() + ? Set.of() + : new HashSet<>(repoRepository + .findGithubRepoIdsByUser_IdAndDeletedFalseAndGithubRepoIdIn(userId, githubIds)); + + List content = listPage.repos().stream() + .map(r -> new CandidateRepositoryResult( + r.id(), r.name(), r.fullName(), r.htmlUrl(), r.defaultBranch(), + r.privateRepo(), r.description(), registered.contains(r.id()) + )) + .toList(); + + return new CandidateRepositoryListResult(content, page, perPage, listPage.hasNext()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryListResult.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryListResult.java new file mode 100644 index 00000000..ad3c2f93 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryListResult.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.github.application.dto; + +import java.util.List; + +public record CandidateRepositoryListResult( + List content, + int page, + int perPage, + boolean hasNext +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java new file mode 100644 index 00000000..40d5a1dd --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.github.application.dto; + +public record CandidateRepositoryResult( + Long githubRepoId, + String name, + String fullName, + String htmlUrl, + String defaultBranch, + boolean privateRepo, + String description, + boolean alreadyRegistered +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java new file mode 100644 index 00000000..618f49ee --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java @@ -0,0 +1,26 @@ +package com.stackup.stackup.github.application.dto; + +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.github.domain.RepositoryStatus; +import java.time.Instant; + +public record GithubRepositoryResult( + Long id, + Long githubRepoId, + String repoName, + String repoFullName, + String repoUrl, + String defaultBranch, + RepositoryStatus status, + Instant lastSyncedAt, + Instant createdAt, + Instant updatedAt +) { + public static GithubRepositoryResult from(GithubRepository r) { + return new GithubRepositoryResult( + r.getId(), r.getGithubRepoId(), r.getRepoName(), r.getRepoFullName(), + r.getRepoUrl(), r.getDefaultBranch(), r.getStatus(), + r.getLastSyncedAt(), r.getCreatedAt(), r.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java new file mode 100644 index 00000000..7acb89ab --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java @@ -0,0 +1,4 @@ +package com.stackup.stackup.github.application.dto; + +public record RegisterRepositoryCommand(Long githubRepoId) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java new file mode 100644 index 00000000..d5a2a41b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.github.application.event; + +public record RepositoryRegisteredEvent( + Long repositoryId, + Long userId, + String githubFullName, + String defaultBranch, + String encryptedGithubAccessToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index 0e1e0b61..3b0de62f 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -64,4 +64,33 @@ public class GithubRepository extends BaseSoftDeleteEntity { @Column(name = "last_synced_at") private Instant lastSyncedAt; + + public static GithubRepository create( + User user, + Long githubRepoId, + String repoName, + String repoFullName, + String repoUrl, + String defaultBranch + ) { + GithubRepository repo = new GithubRepository(); + repo.user = user; + repo.githubRepoId = githubRepoId; + repo.repoName = repoName; + repo.repoFullName = repoFullName; + repo.repoUrl = repoUrl; + repo.defaultBranch = defaultBranch == null ? "main" : defaultBranch; + repo.status = RepositoryStatus.PENDING; + return repo; + } + + public void resurrect(String repoName, String repoFullName, String repoUrl, String defaultBranch) { + this.deleted = false; + this.repoName = repoName; + this.repoFullName = repoFullName; + this.repoUrl = repoUrl; + this.defaultBranch = defaultBranch == null ? "main" : defaultBranch; + this.status = RepositoryStatus.PENDING; + this.lastSyncedAt = null; + } } diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java index 8301dc61..bffc8de0 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java @@ -1,12 +1,28 @@ package com.stackup.stackup.github.domain; +import java.util.Collection; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface GithubRepositoryRepository extends JpaRepository { - List findByUser_IdAndDeletedFalse(Long userId); + Page findByUser_IdAndDeletedFalse(Long userId, Pageable pageable); Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); + + Optional findByUser_IdAndGithubRepoId(Long userId, Long githubRepoId); + + @Query(""" + select g.githubRepoId from GithubRepository g + where g.user.id = :userId and g.deleted = false and g.githubRepoId in :githubRepoIds + """) + List findGithubRepoIdsByUser_IdAndDeletedFalseAndGithubRepoIdIn( + @Param("userId") Long userId, + @Param("githubRepoIds") Collection githubRepoIds + ); } diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApi.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApi.java new file mode 100644 index 00000000..aae77bcc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApi.java @@ -0,0 +1,37 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +@HttpExchange +public interface GithubApi { + + @GetExchange("/user") + GithubUserResponse getUser(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization); + + @GetExchange("/user/emails") + GithubEmailResponse[] getEmails(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization); + + @GetExchange("/repositories/{id}") + GithubRepoResponse getRepository( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @PathVariable long id + ); + + @GetExchange("/user/repos") + ResponseEntity listUserRepos( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestParam("per_page") int perPage, + @RequestParam("page") int page, + @RequestParam("sort") String sort, + @RequestParam("affiliation") String affiliation + ); +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java index 3128046c..b1c2744e 100644 --- a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java @@ -1,41 +1,37 @@ package com.stackup.stackup.github.infrastructure; -import com.stackup.stackup.common.config.properties.GithubOAuthProperties; import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoListPage; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse; import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; import java.util.Arrays; +import java.util.List; import java.util.Optional; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; @Component +@RequiredArgsConstructor public class GithubApiClient { - private final RestClient restClient; + private static final String BEARER_PREFIX = "Bearer "; + private static final String SORT_UPDATED = "updated"; + private static final String AFFILIATION = "owner,collaborator,organization_member"; + private static final String RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining"; + private static final String LINK_HEADER = "Link"; - public GithubApiClient(GithubOAuthProperties githubOAuthProperties) { - this.restClient = RestClient.builder() - .baseUrl(githubOAuthProperties.apiBaseUrl()) - .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json") - .defaultHeader("X-GitHub-Api-Version", githubOAuthProperties.apiVersion()) - .build(); - } + private final GithubApi githubApi; public GithubUserResponse getUser(String githubAccessToken) { try { - GithubUserResponse response = restClient.get() - .uri("/user") - .headers(headers -> headers.setBearerAuth(githubAccessToken)) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .body(GithubUserResponse.class); - - if (response == null || response.id() == null || response.login() == null || response.login().isBlank()) { + GithubUserResponse response = githubApi.getUser(bearer(githubAccessToken)); + if (response == null || response.id() == null + || response.login() == null || response.login().isBlank()) { throw oauthFailed(); } return response; @@ -46,17 +42,10 @@ public GithubUserResponse getUser(String githubAccessToken) { public Optional getPrimaryVerifiedEmail(String githubAccessToken) { try { - GithubEmailResponse[] response = restClient.get() - .uri("/user/emails") - .headers(headers -> headers.setBearerAuth(githubAccessToken)) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .body(GithubEmailResponse[].class); - + GithubEmailResponse[] response = githubApi.getEmails(bearer(githubAccessToken)); if (response == null) { return Optional.empty(); } - return Arrays.stream(response) .filter(GithubEmailResponse::primary) .filter(GithubEmailResponse::verified) @@ -68,6 +57,63 @@ public Optional getPrimaryVerifiedEmail(String githubAccessToken) { } } + public GithubRepoResponse getRepository(String accessToken, long githubRepoId) { + try { + GithubRepoResponse body = githubApi.getRepository(bearer(accessToken), githubRepoId); + if (body == null || body.id() == null) { + throw new DomainException(ApiErrorCode.REPO_GITHUB_API_FAILED); + } + return body; + } catch (HttpClientErrorException.NotFound e) { + throw new DomainException(ApiErrorCode.REPO_PRIVATE_NO_ACCESS); + } catch (HttpClientErrorException.Forbidden e) { + String remaining = e.getResponseHeaders() == null ? null + : e.getResponseHeaders().getFirst(RATE_LIMIT_REMAINING_HEADER); + throw "0".equals(remaining) + ? new DomainException(ApiErrorCode.SYS_RATE_LIMITED) + : new DomainException(ApiErrorCode.REPO_PRIVATE_NO_ACCESS); + } catch (DomainException e) { + throw e; + } catch (RestClientException exception) { + throw githubApiFailed(exception); + } + } + + public GithubRepoListPage listUserRepositories(String accessToken, int page, int perPage) { + try { + ResponseEntity response = githubApi.listUserRepos( + bearer(accessToken), perPage, page, SORT_UPDATED, AFFILIATION + ); + GithubRepoResponse[] body = response.getBody(); + List repos = body == null ? List.of() : Arrays.asList(body); + boolean hasNext = parseLinkHasNext(response.getHeaders().getFirst(LINK_HEADER)); + return new GithubRepoListPage(repos, hasNext); + } catch (RestClientException exception) { + throw githubApiFailed(exception); + } + } + + private static String bearer(String token) { + return BEARER_PREFIX + token; + } + + private static boolean parseLinkHasNext(String linkHeader) { + if (linkHeader == null || linkHeader.isBlank()) { + return false; + } + return Arrays.stream(linkHeader.split(",")) + .map(String::trim) + .anyMatch(seg -> seg.endsWith("rel=\"next\"")); + } + + private DomainException githubApiFailed(Throwable cause) { + return new DomainException( + ApiErrorCode.REPO_GITHUB_API_FAILED, + ApiErrorCode.REPO_GITHUB_API_FAILED.getDefaultMessage(), + cause + ); + } + private DomainException oauthFailed() { return new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); } diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubInfrastructureConfig.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubInfrastructureConfig.java new file mode 100644 index 00000000..53b35c4d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubInfrastructureConfig.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.common.config.properties.GithubOAuthProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class GithubInfrastructureConfig { + + @Bean + public GithubApi githubApi(GithubOAuthProperties properties) { + return githubApi(properties, RestClient.builder()); + } + + GithubApi githubApi(GithubOAuthProperties properties, RestClient.Builder restClientBuilder) { + RestClient restClient = restClientBuilder + .baseUrl(properties.apiBaseUrl()) + .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json") + .defaultHeader("X-GitHub-Api-Version", properties.apiVersion()) + .build(); + return HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(restClient)) + .build() + .createClient(GithubApi.class); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/RepositoryAnalysisPublisher.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/RepositoryAnalysisPublisher.java new file mode 100644 index 00000000..dc241adb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/RepositoryAnalysisPublisher.java @@ -0,0 +1,42 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.retry.RetryPolicy; +import com.stackup.stackup.common.retry.RetryingExecutor; +import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent; +import com.stackup.stackup.github.infrastructure.dto.AnalyzeRepositoryPayload; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class RepositoryAnalysisPublisher { + + private static final RetryPolicy RETRY_POLICY = RetryPolicy.exponentialBackoff(500, 3); + + private final RabbitMessagePublisher rabbitPublisher; + private final RabbitMqProperties properties; + private final RetryingExecutor retryingExecutor; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(RepositoryRegisteredEvent event) { + String routingKey = properties.routingKeys().analyzeRepository(); + AnalyzeRepositoryPayload payload = new AnalyzeRepositoryPayload( + event.repositoryId(), + event.githubFullName(), + event.defaultBranch(), + event.encryptedGithubAccessToken() + ); + Map context = Map.of("userId", event.userId()); + + retryingExecutor.execute( + "analyze.repository[repositoryId=" + event.repositoryId() + ",userId=" + event.userId() + "]", + RETRY_POLICY, + () -> rabbitPublisher.publishToAi(routingKey, payload, context) + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/AnalyzeRepositoryPayload.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/AnalyzeRepositoryPayload.java new file mode 100644 index 00000000..79139ee3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/AnalyzeRepositoryPayload.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.github.infrastructure.dto; + +public record AnalyzeRepositoryPayload( + Long repositoryId, + String githubFullName, + String defaultBranch, + String githubAccessTokenEncrypted +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoListPage.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoListPage.java new file mode 100644 index 00000000..121badc1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoListPage.java @@ -0,0 +1,6 @@ +package com.stackup.stackup.github.infrastructure.dto; + +import java.util.List; + +public record GithubRepoListPage(List repos, boolean hasNext) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java new file mode 100644 index 00000000..9c81d5ed --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java @@ -0,0 +1,14 @@ +package com.stackup.stackup.github.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GithubRepoResponse( + Long id, + String name, + @JsonProperty("full_name") String fullName, + @JsonProperty("html_url") String htmlUrl, + @JsonProperty("default_branch") String defaultBranch, + @JsonProperty("private") boolean privateRepo, + String description +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java new file mode 100644 index 00000000..fc181154 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java @@ -0,0 +1,98 @@ +package com.stackup.stackup.github.presentation; + +import com.stackup.stackup.common.response.PageResponse; +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.github.application.GithubRepositoryService; +import com.stackup.stackup.github.application.dto.GithubRepositoryResult; +import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand; +import com.stackup.stackup.github.presentation.dto.CandidateRepositoryPageResponse; +import com.stackup.stackup.github.presentation.dto.RegisterRepositoryRequest; +import com.stackup.stackup.github.presentation.dto.RegisteredRepositoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/repositories") +@Tag(name = "Repositories", description = "GitHub 레포지토리 등록 및 관리") +public class GithubRepositoryController { + + private final GithubRepositoryService service; + + public GithubRepositoryController(GithubRepositoryService service) { + this.service = service; + } + + @PostMapping + @Operation(summary = "GitHub 레포 등록") + public ResponseEntity register( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody RegisterRepositoryRequest request + ) { + GithubRepositoryResult result = service.register( + principal.userId(), + new RegisterRepositoryCommand(request.githubRepoId()) + ); + return ResponseEntity + .created(URI.create("/api/repositories/" + result.id())) + .body(RegisteredRepositoryResponse.from(result)); + } + + @GetMapping + @Operation(summary = "내가 등록한 레포 목록") + public PageResponse list( + @AuthenticationPrincipal UserPrincipal principal, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return PageResponse.from(service.list(principal.userId(), pageable).map(RegisteredRepositoryResponse::from)); + } + + @GetMapping("/github") + @Operation(summary = "GitHub 레포 후보 목록") + public CandidateRepositoryPageResponse listCandidates( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "30") int perPage + ) { + int safePerPage = Math.min(Math.max(perPage, 1), 100); + int safePage = Math.max(page, 1); + return CandidateRepositoryPageResponse.from( + service.listCandidates(principal.userId(), safePage, safePerPage) + ); + } + + @GetMapping("/{id}") + @Operation(summary = "등록 레포 단건") + public RegisteredRepositoryResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + return RegisteredRepositoryResponse.from(service.get(principal.userId(), id)); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "등록 레포 소프트 삭제") + public void delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + service.delete(principal.userId(), id); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryPageResponse.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryPageResponse.java new file mode 100644 index 00000000..687d99a1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryPageResponse.java @@ -0,0 +1,18 @@ +package com.stackup.stackup.github.presentation.dto; + +import com.stackup.stackup.github.application.dto.CandidateRepositoryListResult; +import java.util.List; + +public record CandidateRepositoryPageResponse( + List content, + int page, + int perPage, + boolean hasNext +) { + public static CandidateRepositoryPageResponse from(CandidateRepositoryListResult result) { + return new CandidateRepositoryPageResponse( + result.content().stream().map(CandidateRepositoryResponse::from).toList(), + result.page(), result.perPage(), result.hasNext() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java new file mode 100644 index 00000000..ade8c371 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java @@ -0,0 +1,22 @@ +package com.stackup.stackup.github.presentation.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.stackup.stackup.github.application.dto.CandidateRepositoryResult; + +public record CandidateRepositoryResponse( + Long githubRepoId, + String name, + String fullName, + String htmlUrl, + String defaultBranch, + @JsonProperty("private") boolean privateRepo, + String description, + boolean alreadyRegistered +) { + public static CandidateRepositoryResponse from(CandidateRepositoryResult c) { + return new CandidateRepositoryResponse( + c.githubRepoId(), c.name(), c.fullName(), c.htmlUrl(), c.defaultBranch(), + c.privateRepo(), c.description(), c.alreadyRegistered() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java new file mode 100644 index 00000000..1aa5ed5e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.github.presentation.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record RegisterRepositoryRequest( + @NotNull @Positive Long githubRepoId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java new file mode 100644 index 00000000..0565ead4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java @@ -0,0 +1,25 @@ +package com.stackup.stackup.github.presentation.dto; + +import com.stackup.stackup.github.application.dto.GithubRepositoryResult; +import com.stackup.stackup.github.domain.RepositoryStatus; +import java.time.Instant; + +public record RegisteredRepositoryResponse( + Long id, + Long githubRepoId, + String repoName, + String repoFullName, + String repoUrl, + String defaultBranch, + RepositoryStatus status, + Instant lastSyncedAt, + Instant createdAt +) { + public static RegisteredRepositoryResponse from(GithubRepositoryResult r) { + return new RegisteredRepositoryResponse( + r.id(), r.githubRepoId(), r.repoName(), r.repoFullName(), + r.repoUrl(), r.defaultBranch(), r.status(), + r.lastSyncedAt(), r.createdAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java b/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java index b7261a69..3a02476c 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java +++ b/backend/src/main/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisher.java @@ -2,11 +2,12 @@ import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.retry.RetryPolicy; +import com.stackup.stackup.common.retry.RetryingExecutor; import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; import com.stackup.stackup.resume.infrastructure.dto.AnalyzeResumePayload; import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -16,19 +17,22 @@ @RequiredArgsConstructor public class ResumeAnalysisPublisher { + private static final RetryPolicy RETRY_POLICY = RetryPolicy.exponentialBackoff(500, 5); + private final RabbitMessagePublisher rabbitPublisher; private final RabbitMqProperties properties; + private final RetryingExecutor retryingExecutor; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(ResumeUploadedEvent event) { - try { - rabbitPublisher.publishToAi( - properties.routingKeys().analyzeResume(), - new AnalyzeResumePayload(event.resumeId(), event.s3Key()), - Map.of("userId", event.userId()) - ); - } catch (RuntimeException publishError) { - log.warn("Failed to publish analyze.resume for resumeId={}", event.resumeId(), publishError); - } + String routingKey = properties.routingKeys().analyzeResume(); + AnalyzeResumePayload payload = new AnalyzeResumePayload(event.resumeId(), event.s3Key()); + Map context = Map.of("userId", event.userId()); + + retryingExecutor.execute( + "analyze.resume[resumeId=" + event.resumeId() + ",userId=" + event.userId() + "]", + RETRY_POLICY, + () -> rabbitPublisher.publishToAi(routingKey, payload, context) + ); } } diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index 19421b04..f8c9de1a 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -3,6 +3,7 @@ import com.stackup.stackup.auth.domain.OAuthStateRepository; import com.stackup.stackup.auth.domain.RefreshTokenRepository; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; import com.stackup.stackup.resume.domain.ResumeRepository; import com.stackup.stackup.user.domain.UserRepository; import org.junit.jupiter.api.Test; @@ -27,6 +28,9 @@ class StackupApplicationTests { @MockitoBean private AnalyzedDocumentRepository analyzedDocumentRepository; + @MockitoBean + private GithubRepositoryRepository githubRepositoryRepository; + @Test void contextLoads() { } diff --git a/backend/src/test/java/com/stackup/stackup/common/retry/RetryingExecutorTest.java b/backend/src/test/java/com/stackup/stackup/common/retry/RetryingExecutorTest.java new file mode 100644 index 00000000..a2a66c1f --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/retry/RetryingExecutorTest.java @@ -0,0 +1,126 @@ +package com.stackup.stackup.common.retry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RetryingExecutorTest { + + private final List sleeps = new ArrayList<>(); + + private final RetryingExecutor executor = new RetryingExecutor() { + @Override + protected boolean sleep(long millis) { + sleeps.add(millis); + return true; + } + }; + + @Test + void executes_once_when_action_succeeds() { + int[] calls = {0}; + + executor.execute("op", RetryPolicy.of(100L, 200L), () -> calls[0]++); + + assertThat(calls[0]).isEqualTo(1); + assertThat(sleeps).isEmpty(); + } + + @Test + void retries_then_succeeds_on_second_attempt() { + int[] calls = {0}; + + executor.execute("op", RetryPolicy.of(50L, 100L), () -> { + calls[0]++; + if (calls[0] == 1) { + throw new IllegalStateException("first attempt fails"); + } + }); + + assertThat(calls[0]).isEqualTo(2); + assertThat(sleeps).containsExactly(50L); + } + + @Test + void swallows_exception_after_exhausting_attempts() { + int[] calls = {0}; + + executor.execute("op", RetryPolicy.of(10L, 20L), () -> { + calls[0]++; + throw new IllegalStateException("always fails"); + }); + + assertThat(calls[0]).isEqualTo(3); + assertThat(sleeps).containsExactly(10L, 20L); + } + + @Test + void executeOrThrow_propagates_final_failure() { + int[] calls = {0}; + + assertThatThrownBy(() -> executor.executeOrThrow("op", RetryPolicy.of(1L), () -> { + calls[0]++; + throw new IllegalStateException("nope"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessage("nope"); + assertThat(calls[0]).isEqualTo(2); + } + + @Test + void executeOrThrow_returns_value_on_success() { + Integer result = executor.executeOrThrow("op", RetryPolicy.of(1L), () -> 42); + + assertThat(result).isEqualTo(42); + } + + @Test + void interrupt_during_backoff_stops_retry_and_swallows() { + int[] calls = {0}; + RetryingExecutor interruptingExecutor = new RetryingExecutor() { + @Override + protected boolean sleep(long millis) { + return false; + } + }; + + interruptingExecutor.execute("op", RetryPolicy.of(50L, 100L), () -> { + calls[0]++; + throw new IllegalStateException("fail"); + }); + + assertThat(calls[0]).isEqualTo(1); + } + + @Test + void zero_backoff_policy_means_immediate_retry() { + RetryPolicy immediate = RetryPolicy.of(0L, 0L); + int[] calls = {0}; + + executor.execute("op", immediate, () -> { + calls[0]++; + if (calls[0] < 3) { + throw new IllegalStateException("retry"); + } + }); + + assertThat(calls[0]).isEqualTo(3); + assertThat(sleeps).containsExactly(0L, 0L); + } + + @Test + void policy_rejects_negative_backoff() { + assertThatThrownBy(() -> RetryPolicy.of(100L, -1L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void policy_max_attempts_equals_backoff_length_plus_one() { + assertThat(RetryPolicy.of().maxAttempts()).isEqualTo(1); + assertThat(RetryPolicy.of(1L).maxAttempts()).isEqualTo(2); + assertThat(RetryPolicy.of(1L, 2L, 3L).maxAttempts()).isEqualTo(4); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/application/GithubRepositoryServiceTest.java b/backend/src/test/java/com/stackup/stackup/github/application/GithubRepositoryServiceTest.java new file mode 100644 index 00000000..1c0a51bd --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/application/GithubRepositoryServiceTest.java @@ -0,0 +1,250 @@ +package com.stackup.stackup.github.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.github.infrastructure.GithubApiClient; +import com.stackup.stackup.github.infrastructure.GithubTokenCipher; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +class GithubRepositoryServiceTest { + + private GithubRepositoryRepository repoRepository; + private UserRepository userRepository; + private GithubApiClient githubApiClient; + private GithubTokenCipher tokenCipher; + private ApplicationEventPublisher events; + private GithubRepositoryService service; + + @BeforeEach + void setUp() { + repoRepository = Mockito.mock(GithubRepositoryRepository.class); + userRepository = Mockito.mock(UserRepository.class); + githubApiClient = Mockito.mock(GithubApiClient.class); + tokenCipher = Mockito.mock(GithubTokenCipher.class); + events = Mockito.mock(ApplicationEventPublisher.class); + service = new GithubRepositoryService( + repoRepository, userRepository, githubApiClient, tokenCipher, events + ); + } + + @Test + void list_returns_page_of_user_repositories() { + var pageable = PageRequest.of(0, 20); + GithubRepository repo = GithubRepository.create( + Mockito.mock(User.class), 1L, "n", "u/n", "https://x", "main" + ); + org.springframework.test.util.ReflectionTestUtils.setField(repo, "id", 7L); + var page = new PageImpl<>(java.util.List.of(repo), pageable, 1); + when(repoRepository.findByUser_IdAndDeletedFalse(eq(42L), eq(pageable))).thenReturn(page); + + var result = service.list(42L, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).id()).isEqualTo(7L); + } + + @Test + void get_throws_when_not_found_or_other_user() { + when(repoRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> service.get(42L, 1L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.REPO_NOT_FOUND); + } + + @Test + void get_returns_repository_when_owner() { + GithubRepository repo = GithubRepository.create( + Mockito.mock(User.class), 1L, "n", "u/n", "https://x", "main" + ); + org.springframework.test.util.ReflectionTestUtils.setField(repo, "id", 1L); + when(repoRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.of(repo)); + + var result = service.get(42L, 1L); + + assertThat(result.id()).isEqualTo(1L); + } + + @Test + void delete_throws_repo_not_found_for_unknown() { + when(repoRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> service.delete(42L, 1L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.REPO_NOT_FOUND); + } + + @Test + void delete_marks_repository_deleted_when_present() { + GithubRepository repo = GithubRepository.create( + Mockito.mock(User.class), 1L, "n", "u/n", "https://x", "main" + ); + org.springframework.test.util.ReflectionTestUtils.setField(repo, "id", 1L); + when(repoRepository.findByIdAndUser_IdAndDeletedFalse(1L, 42L)).thenReturn(java.util.Optional.of(repo)); + + service.delete(42L, 1L); + + assertThat(repo.isDeleted()).isTrue(); + } + + @Test + void list_candidates_proxies_github_and_marks_already_registered() { + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + when(user.getEncryptedGithubAccessToken()).thenReturn("encrypted"); + when(userRepository.findById(42L)).thenReturn(java.util.Optional.of(user)); + when(tokenCipher.decrypt("encrypted")).thenReturn("plain-token"); + + var repoA = new com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse( + 1L, "a", "u/a", "https://x/a", "main", false, "d"); + var repoB = new com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse( + 2L, "b", "u/b", "https://x/b", "main", true, "d"); + var listPage = new com.stackup.stackup.github.infrastructure.dto.GithubRepoListPage( + java.util.List.of(repoA, repoB), true); + when(githubApiClient.listUserRepositories("plain-token", 1, 30)).thenReturn(listPage); + + when(repoRepository.findGithubRepoIdsByUser_IdAndDeletedFalseAndGithubRepoIdIn( + eq(42L), eq(java.util.Set.of(1L, 2L)) + )).thenReturn(java.util.List.of(2L)); + + var result = service.listCandidates(42L, 1, 30); + + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).githubRepoId()).isEqualTo(1L); + assertThat(result.content().get(0).alreadyRegistered()).isFalse(); + assertThat(result.content().get(1).githubRepoId()).isEqualTo(2L); + assertThat(result.content().get(1).alreadyRegistered()).isTrue(); + assertThat(result.page()).isEqualTo(1); + assertThat(result.perPage()).isEqualTo(30); + assertThat(result.hasNext()).isTrue(); + } + + @Test + void list_candidates_throws_when_user_not_found() { + when(userRepository.findById(99L)).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> service.listCandidates(99L, 1, 30)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.USER_NOT_FOUND); + } + + @Test + void register_happy_path_persists_repo_and_publishes_event() { + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + when(user.getEncryptedGithubAccessToken()).thenReturn("enc"); + when(userRepository.findById(42L)).thenReturn(java.util.Optional.of(user)); + when(tokenCipher.decrypt("enc")).thenReturn("plain"); + when(tokenCipher.encrypt("plain")).thenReturn("sealed"); + when(githubApiClient.getRepository("plain", 1296269L)).thenReturn( + new com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse( + 1296269L, "Hello-World", "octocat/Hello-World", + "https://github.com/octocat/Hello-World", "main", false, "d") + ); + when(repoRepository.findByUser_IdAndGithubRepoId(42L, 1296269L)) + .thenReturn(java.util.Optional.empty()); + when(repoRepository.save(org.mockito.ArgumentMatchers.any())).thenAnswer(inv -> { + GithubRepository r = inv.getArgument(0); + org.springframework.test.util.ReflectionTestUtils.setField(r, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(r, "createdAt", java.time.Instant.now()); + org.springframework.test.util.ReflectionTestUtils.setField(r, "updatedAt", java.time.Instant.now()); + return r; + }); + + var result = service.register(42L, + new com.stackup.stackup.github.application.dto.RegisterRepositoryCommand(1296269L)); + + assertThat(result.id()).isEqualTo(7L); + assertThat(result.repoFullName()).isEqualTo("octocat/Hello-World"); + + var captor = org.mockito.ArgumentCaptor.forClass( + com.stackup.stackup.github.application.event.RepositoryRegisteredEvent.class); + Mockito.verify(events).publishEvent(captor.capture()); + assertThat(captor.getValue().repositoryId()).isEqualTo(7L); + assertThat(captor.getValue().userId()).isEqualTo(42L); + assertThat(captor.getValue().githubFullName()).isEqualTo("octocat/Hello-World"); + assertThat(captor.getValue().defaultBranch()).isEqualTo("main"); + assertThat(captor.getValue().encryptedGithubAccessToken()).isEqualTo("sealed"); + } + + @Test + void register_resurrects_deleted_row_instead_of_409() { + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + when(user.getEncryptedGithubAccessToken()).thenReturn("enc"); + when(userRepository.findById(42L)).thenReturn(java.util.Optional.of(user)); + when(tokenCipher.decrypt("enc")).thenReturn("plain"); + when(tokenCipher.encrypt("plain")).thenReturn("sealed"); + when(githubApiClient.getRepository("plain", 1L)).thenReturn( + new com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse( + 1L, "new", "u/new", "https://x", "main", false, "d") + ); + + GithubRepository deletedRow = GithubRepository.create(user, 1L, "old", "u/old", "https://x", "master"); + org.springframework.test.util.ReflectionTestUtils.setField(deletedRow, "id", 5L); + deletedRow.softDelete(); + when(repoRepository.findByUser_IdAndGithubRepoId(42L, 1L)) + .thenReturn(java.util.Optional.of(deletedRow)); + + var result = service.register(42L, + new com.stackup.stackup.github.application.dto.RegisterRepositoryCommand(1L)); + + assertThat(result.id()).isEqualTo(5L); + assertThat(deletedRow.isDeleted()).isFalse(); + assertThat(deletedRow.getRepoFullName()).isEqualTo("u/new"); + Mockito.verify(events).publishEvent( + org.mockito.ArgumentMatchers.any( + com.stackup.stackup.github.application.event.RepositoryRegisteredEvent.class)); + } + + @Test + void register_throws_409_when_active_row_already_exists() { + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + when(user.getEncryptedGithubAccessToken()).thenReturn("enc"); + when(userRepository.findById(42L)).thenReturn(java.util.Optional.of(user)); + when(tokenCipher.decrypt("enc")).thenReturn("plain"); + when(githubApiClient.getRepository("plain", 1L)).thenReturn( + new com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse( + 1L, "n", "u/n", "https://x", "main", false, "d") + ); + + GithubRepository activeRow = GithubRepository.create(user, 1L, "n", "u/n", "https://x", "main"); + org.springframework.test.util.ReflectionTestUtils.setField(activeRow, "id", 9L); + when(repoRepository.findByUser_IdAndGithubRepoId(42L, 1L)) + .thenReturn(java.util.Optional.of(activeRow)); + + assertThatThrownBy(() -> service.register(42L, + new com.stackup.stackup.github.application.dto.RegisterRepositoryCommand(1L))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.REPO_ALREADY_REGISTERED); + + Mockito.verify(events, Mockito.never()).publishEvent( + org.mockito.ArgumentMatchers.any()); + } + + @Test + void register_propagates_private_no_access_from_api_client() { + com.stackup.stackup.user.domain.User user = Mockito.mock(com.stackup.stackup.user.domain.User.class); + when(user.getEncryptedGithubAccessToken()).thenReturn("enc"); + when(userRepository.findById(42L)).thenReturn(java.util.Optional.of(user)); + when(tokenCipher.decrypt("enc")).thenReturn("plain"); + when(githubApiClient.getRepository("plain", 99L)).thenThrow( + new DomainException(ApiErrorCode.REPO_PRIVATE_NO_ACCESS)); + + assertThatThrownBy(() -> service.register(42L, + new com.stackup.stackup.github.application.dto.RegisterRepositoryCommand(99L))) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.REPO_PRIVATE_NO_ACCESS); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/domain/GithubRepositoryRepositoryTest.java b/backend/src/test/java/com/stackup/stackup/github/domain/GithubRepositoryRepositoryTest.java new file mode 100644 index 00000000..3106b773 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/domain/GithubRepositoryRepositoryTest.java @@ -0,0 +1,116 @@ +package com.stackup.stackup.github.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest( + classes = GithubRepositoryRepositoryTest.TestConfig.class, + webEnvironment = WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.autoconfigure.exclude=", + "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///testdb", + "spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration", + "spring.jpa.open-in-view=false", + "app.security.jwt-secret=test-secret", + "app.security.encryption-key=test-encryption-key", + "app.github.client-id=test", + "app.github.client-secret=test", + "app.github.redirect-uri=http://localhost" +}) +@Transactional +class GithubRepositoryRepositoryTest { + + @Configuration + @EnableAutoConfiguration(exclude = { + RabbitAutoConfiguration.class, + SecurityAutoConfiguration.class, + SecurityFilterAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class + }) + @EntityScan({ + "com.stackup.stackup.user.domain", + "com.stackup.stackup.auth.domain", + "com.stackup.stackup.resume.domain", + "com.stackup.stackup.github.domain", + "com.stackup.stackup.document.domain", + "com.stackup.stackup.session.domain", + "com.stackup.stackup.log.activity.domain", + "com.stackup.stackup.log.ai.domain" + }) + @EnableJpaRepositories(basePackages = "com.stackup.stackup") + static class TestConfig {} + + @Autowired GithubRepositoryRepository repoRepository; + @Autowired UserRepository userRepository; + + @Test + void page_finder_excludes_deleted_and_other_users() { + User a = userRepository.save(User.createGithubUser(7001L, "alice", "a@x", null, "tok")); + User b = userRepository.save(User.createGithubUser(7002L, "bob", "b@x", null, "tok")); + repoRepository.save(GithubRepository.create(a, 1L, "r1", "a/r1", "https://x", "main")); + GithubRepository del = repoRepository.save(GithubRepository.create(a, 2L, "r2", "a/r2", "https://x", "main")); + del.softDelete(); + repoRepository.save(del); + repoRepository.save(GithubRepository.create(b, 3L, "rb", "b/rb", "https://x", "main")); + + var page = repoRepository.findByUser_IdAndDeletedFalse( + a.getId(), PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent()).extracting(GithubRepository::getGithubRepoId).containsExactly(1L); + } + + @Test + void unfiltered_finder_returns_deleted_row_for_resurrect() { + User u = userRepository.save(User.createGithubUser(7003L, "u", "u@x", null, "tok")); + GithubRepository repo = repoRepository.save(GithubRepository.create(u, 9L, "r", "u/r", "https://x", "main")); + repo.softDelete(); + repoRepository.save(repo); + + var found = repoRepository.findByUser_IdAndGithubRepoId(u.getId(), 9L); + + assertThat(found).isPresent(); + assertThat(found.get().isDeleted()).isTrue(); + } + + @Test + void already_registered_projection_returns_ids_in_use() { + User u = userRepository.save(User.createGithubUser(7004L, "u4", "u4@x", null, "tok")); + repoRepository.save(GithubRepository.create(u, 10L, "a", "u/a", "https://x", "main")); + repoRepository.save(GithubRepository.create(u, 11L, "b", "u/b", "https://x", "main")); + GithubRepository del = repoRepository.save(GithubRepository.create(u, 12L, "c", "u/c", "https://x", "main")); + del.softDelete(); + repoRepository.save(del); + + List ids = repoRepository.findGithubRepoIdsByUser_IdAndDeletedFalseAndGithubRepoIdIn( + u.getId(), Set.of(10L, 11L, 12L, 99L) + ); + + assertThat(ids).containsExactlyInAnyOrder(10L, 11L); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/domain/GithubRepositoryTest.java b/backend/src/test/java/com/stackup/stackup/github/domain/GithubRepositoryTest.java new file mode 100644 index 00000000..ab37be76 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/domain/GithubRepositoryTest.java @@ -0,0 +1,45 @@ +package com.stackup.stackup.github.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.user.domain.User; +import org.junit.jupiter.api.Test; + +class GithubRepositoryTest { + + @Test + void create_initializes_pending_repository() { + User user = org.mockito.Mockito.mock(User.class); + GithubRepository repo = GithubRepository.create( + user, 1296269L, "Hello-World", "octocat/Hello-World", + "https://github.com/octocat/Hello-World", "main" + ); + + assertThat(repo.getGithubRepoId()).isEqualTo(1296269L); + assertThat(repo.getRepoName()).isEqualTo("Hello-World"); + assertThat(repo.getRepoFullName()).isEqualTo("octocat/Hello-World"); + assertThat(repo.getRepoUrl()).isEqualTo("https://github.com/octocat/Hello-World"); + assertThat(repo.getDefaultBranch()).isEqualTo("main"); + assertThat(repo.getStatus()).isEqualTo(RepositoryStatus.PENDING); + assertThat(repo.isDeleted()).isFalse(); + } + + @Test + void resurrect_revives_deleted_repository_and_refreshes_metadata() { + User user = org.mockito.Mockito.mock(User.class); + GithubRepository repo = GithubRepository.create( + user, 1L, "old", "u/old", "https://x", "master" + ); + repo.softDelete(); + assertThat(repo.isDeleted()).isTrue(); + + repo.resurrect("new", "u/new", "https://y", "main"); + + assertThat(repo.isDeleted()).isFalse(); + assertThat(repo.getRepoName()).isEqualTo("new"); + assertThat(repo.getRepoFullName()).isEqualTo("u/new"); + assertThat(repo.getRepoUrl()).isEqualTo("https://y"); + assertThat(repo.getDefaultBranch()).isEqualTo("main"); + assertThat(repo.getStatus()).isEqualTo(RepositoryStatus.PENDING); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubApiClientTest.java b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubApiClientTest.java new file mode 100644 index 00000000..0fc21102 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubApiClientTest.java @@ -0,0 +1,116 @@ +package com.stackup.stackup.github.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.stackup.stackup.common.config.properties.GithubOAuthProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoListPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class GithubApiClientTest { + + private MockRestServiceServer server; + private GithubApiClient client; + + @BeforeEach + void setUp() { + GithubOAuthProperties props = Mockito.mock(GithubOAuthProperties.class); + when(props.apiBaseUrl()).thenReturn("https://api.github.com"); + when(props.apiVersion()).thenReturn("2022-11-28"); + + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + + GithubApi api = new GithubInfrastructureConfig().githubApi(props, builder); + client = new GithubApiClient(api); + } + + @Test + void list_user_repositories_parses_link_header_and_returns_repos() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Link", + "; rel=\"next\", " + + "; rel=\"last\""); + String body = """ + [{"id":1,"name":"hello","full_name":"o/hello","html_url":"https://github.com/o/hello", + "default_branch":"main","private":false,"description":"d"}]"""; + + server.expect(requestTo("https://api.github.com/user/repos?per_page=30&page=1&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON).headers(headers)); + + GithubRepoListPage page = client.listUserRepositories("ghp_token", 1, 30); + + assertThat(page.repos()).hasSize(1); + assertThat(page.repos().get(0).fullName()).isEqualTo("o/hello"); + assertThat(page.hasNext()).isTrue(); + + server.verify(); + } + + @Test + void list_user_repositories_hasNext_false_when_link_has_no_next() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Link", "; rel=\"prev\""); + server.expect(requestTo("https://api.github.com/user/repos?per_page=30&page=2&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member")) + .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON).headers(headers)); + + GithubRepoListPage page = client.listUserRepositories("tok", 2, 30); + + assertThat(page.hasNext()).isFalse(); + } + + @Test + void get_repository_returns_metadata_on_success() { + String body = """ + {"id":1296269,"name":"Hello-World","full_name":"octocat/Hello-World", + "html_url":"https://github.com/octocat/Hello-World", + "default_branch":"main","private":false,"description":"d"}"""; + server.expect(requestTo("https://api.github.com/repositories/1296269")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + + var resp = client.getRepository("tok", 1296269L); + + assertThat(resp.id()).isEqualTo(1296269L); + assertThat(resp.fullName()).isEqualTo("octocat/Hello-World"); + } + + @Test + void get_repository_translates_404_to_repo_private_no_access() { + server.expect(requestTo("https://api.github.com/repositories/999")) + .andRespond(withResourceNotFound()); + + assertThatThrownBy(() -> client.getRepository("tok", 999L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.REPO_PRIVATE_NO_ACCESS); + } + + @Test + void get_repository_translates_403_with_zero_remaining_to_sys_rate_limited() { + HttpHeaders headers = new HttpHeaders(); + headers.add("X-RateLimit-Remaining", "0"); + server.expect(requestTo("https://api.github.com/repositories/2")) + .andRespond(withStatus(HttpStatus.FORBIDDEN).headers(headers)); + + assertThatThrownBy(() -> client.getRepository("tok", 2L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.SYS_RATE_LIMITED); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/infrastructure/RepositoryAnalysisPublisherTest.java b/backend/src/test/java/com/stackup/stackup/github/infrastructure/RepositoryAnalysisPublisherTest.java new file mode 100644 index 00000000..63b9ca83 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/infrastructure/RepositoryAnalysisPublisherTest.java @@ -0,0 +1,45 @@ +package com.stackup.stackup.github.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.retry.RetryingExecutor; +import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent; +import com.stackup.stackup.github.infrastructure.dto.AnalyzeRepositoryPayload; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class RepositoryAnalysisPublisherTest { + + @Test + void delegates_to_rabbit_message_publisher_with_analyze_repository_routing_key() { + RabbitMessagePublisher rabbit = Mockito.mock(RabbitMessagePublisher.class); + RabbitMqProperties props = Mockito.mock(RabbitMqProperties.class, Mockito.RETURNS_DEEP_STUBS); + when(props.routingKeys().analyzeRepository()).thenReturn("analyze.repository"); + + RepositoryAnalysisPublisher publisher = new RepositoryAnalysisPublisher(rabbit, props, new RetryingExecutor()); + + publisher.handle(new RepositoryRegisteredEvent(7L, 42L, "u/r", "main", "sealed")); + + ArgumentCaptor rk = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + ArgumentCaptor ctx = ArgumentCaptor.forClass(Map.class); + + verify(rabbit).publishToAi(rk.capture(), payload.capture(), ctx.capture()); + + assertThat(rk.getValue()).isEqualTo("analyze.repository"); + assertThat(payload.getValue()).isInstanceOf(AnalyzeRepositoryPayload.class); + AnalyzeRepositoryPayload p = (AnalyzeRepositoryPayload) payload.getValue(); + assertThat(p.repositoryId()).isEqualTo(7L); + assertThat(p.githubFullName()).isEqualTo("u/r"); + assertThat(p.defaultBranch()).isEqualTo("main"); + assertThat(p.githubAccessTokenEncrypted()).isEqualTo("sealed"); + assertThat(ctx.getValue()).containsEntry("userId", 42L); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/presentation/GithubRepositoryControllerTest.java b/backend/src/test/java/com/stackup/stackup/github/presentation/GithubRepositoryControllerTest.java new file mode 100644 index 00000000..9dc1dabf --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/presentation/GithubRepositoryControllerTest.java @@ -0,0 +1,177 @@ +package com.stackup.stackup.github.presentation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.exception.GlobalExceptionHandler; +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.github.application.GithubRepositoryService; +import com.stackup.stackup.github.application.dto.CandidateRepositoryListResult; +import com.stackup.stackup.github.application.dto.CandidateRepositoryResult; +import com.stackup.stackup.github.application.dto.GithubRepositoryResult; +import com.stackup.stackup.github.domain.RepositoryStatus; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class GithubRepositoryControllerTest { + + @Mock + GithubRepositoryService service; + + @InjectMocks + GithubRepositoryController controller; + + MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers( + new AuthenticationPrincipalArgumentResolver(), + new PageableHandlerMethodArgumentResolver() + ) + .build(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(long userId) { + UserPrincipal principal = new UserPrincipal(userId, "alice", List.of()); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + private GithubRepositoryResult registered(long id, long githubRepoId, String fullName) { + return new GithubRepositoryResult( + id, githubRepoId, fullName.substring(fullName.indexOf('/') + 1), + fullName, "https://github.com/" + fullName, + "main", RepositoryStatus.PENDING, null, + Instant.parse("2026-05-15T10:00:00Z"), Instant.parse("2026-05-15T10:00:00Z") + ); + } + + @Test + void register_returns_201_with_location_header() throws Exception { + authenticateAs(42L); + when(service.register(eq(42L), any())).thenReturn(registered(7L, 1296269L, "octocat/Hello-World")); + + mockMvc.perform(post("/api/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"githubRepoId\": 1296269}")) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/repositories/7")) + .andExpect(jsonPath("$.id").value(7)) + .andExpect(jsonPath("$.repoFullName").value("octocat/Hello-World")) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + @Test + void register_returns_409_when_already_registered() throws Exception { + authenticateAs(42L); + when(service.register(eq(42L), any())) + .thenThrow(new DomainException(ApiErrorCode.REPO_ALREADY_REGISTERED)); + + mockMvc.perform(post("/api/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"githubRepoId\": 1}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("REPO_ALREADY_REGISTERED")); + } + + @Test + void register_returns_403_when_private_no_access() throws Exception { + authenticateAs(42L); + when(service.register(eq(42L), any())) + .thenThrow(new DomainException(ApiErrorCode.REPO_PRIVATE_NO_ACCESS)); + + mockMvc.perform(post("/api/repositories") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"githubRepoId\": 99}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("REPO_PRIVATE_NO_ACCESS")); + } + + @Test + void list_returns_paged_response() throws Exception { + authenticateAs(42L); + var r = registered(1L, 100L, "u/n"); + var page = new PageImpl<>(java.util.List.of(r), PageRequest.of(0, 20), 1); + when(service.list(eq(42L), any())).thenReturn(page); + + mockMvc.perform(get("/api/repositories?page=0&size=20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].id").value(1)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(true)); + } + + @Test + void list_candidates_returns_with_hasNext() throws Exception { + authenticateAs(42L); + var c1 = new CandidateRepositoryResult(1L, "a", "u/a", "https://x/a", "main", false, "d", false); + var c2 = new CandidateRepositoryResult(2L, "b", "u/b", "https://x/b", "main", true, "d", true); + var listResult = new CandidateRepositoryListResult(java.util.List.of(c1, c2), 1, 30, true); + when(service.listCandidates(eq(42L), eq(1), eq(30))).thenReturn(listResult); + + mockMvc.perform(get("/api/repositories/github?page=1&perPage=30")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].githubRepoId").value(1)) + .andExpect(jsonPath("$.content[0].alreadyRegistered").value(false)) + .andExpect(jsonPath("$.content[1].alreadyRegistered").value(true)) + .andExpect(jsonPath("$.page").value(1)) + .andExpect(jsonPath("$.perPage").value(30)) + .andExpect(jsonPath("$.hasNext").value(true)); + } + + @Test + void get_single_returns_404_when_not_owner() throws Exception { + authenticateAs(42L); + when(service.get(42L, 999L)).thenThrow(new DomainException(ApiErrorCode.REPO_NOT_FOUND)); + + mockMvc.perform(get("/api/repositories/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("REPO_NOT_FOUND")); + } + + @Test + void delete_returns_204() throws Exception { + authenticateAs(42L); + mockMvc.perform(delete("/api/repositories/1")) + .andExpect(status().isNoContent()); + verify(service).delete(42L, 1L); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java b/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java index c9135976..a851d0e4 100644 --- a/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/resume/infrastructure/ResumeAnalysisPublisherTest.java @@ -7,6 +7,7 @@ import com.stackup.stackup.common.config.properties.RabbitMqProperties; import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.common.retry.RetryingExecutor; import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; import com.stackup.stackup.resume.infrastructure.dto.AnalyzeResumePayload; import java.util.Map; @@ -22,7 +23,7 @@ void delegates_to_rabbit_message_publisher_with_analyze_resume_routing_key() { RabbitMqProperties props = Mockito.mock(RabbitMqProperties.class, Mockito.RETURNS_DEEP_STUBS); Mockito.when(props.routingKeys().analyzeResume()).thenReturn("analyze.resume"); - ResumeAnalysisPublisher publisher = new ResumeAnalysisPublisher(rabbitPublisher, props); + ResumeAnalysisPublisher publisher = new ResumeAnalysisPublisher(rabbitPublisher, props, new RetryingExecutor()); publisher.handle(new ResumeUploadedEvent(7L, 42L, "resumes/raw/42/abc.pdf"));