diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index cc9d50b..0a0b16f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -2,7 +2,6 @@ name: cd on: push: branches: - - dev - main paths-ignore: - 'README.md' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f89a2eb..0380583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -36,4 +37,12 @@ jobs: run: chmod +x gradlew - name: gradlew test - run: ./gradlew test \ No newline at end of file + run: ./gradlew test + + - name : add coverage to PR + id: jacoco + uses: madrapps/jacoco-report@v1.7.2 + with: + paths: | + ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 831d39b..cb0e6b6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,8 @@ out/ ### VS Code ### .vscode/ -### properties/yml ### +### properties ### /src/main/resources/conal-back-secret/*.properties + +### querydsl Qclass ## +/src/main/generated/ diff --git a/build.gradle b/build.gradle index 02674c0..af912e5 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.6-SNAPSHOT' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'com.specialwarriors' @@ -92,4 +93,55 @@ dependencies { tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = "0.8.11" +} + +def jacocoExcludePatterns = [ + 'com/specialwarriors/conal/ConalApplication.class', + '**/common/**', + '**/exception/**', + '**/dto/**', + '**/config/**', + '**/enums/**', + '**/converter/**', + '**/test/**' +] + +for (qPattern in '**/QA'..'**/QZ') { + jacocoExcludePatterns.add(qPattern + '*') +} + +jacocoTestReport { + reports { + xml.required = true + csv.required = true + html.required = true + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, excludes: jacocoExcludePatterns) + })) + } +} + +jacocoTestCoverageVerification { + violationRules { + + rule { + enabled = true + element = 'CLASS' + + // 커버리지 제외 범위 + excludes = jacocoExcludePatterns + } + } +} + +clean { + delete file('src/main/generated') } diff --git a/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java b/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java index 1daa841..186f7bb 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java @@ -34,7 +34,7 @@ public String createVoteUserToken(String email, Date issuedAt, long expirationMi .compact(); } - public String getEmailFrom(String token) { + public String extractEmailFrom(String token) { return Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token) diff --git a/src/main/java/com/specialwarriors/conal/github_repo/domain/GithubRepo.java b/src/main/java/com/specialwarriors/conal/github_repo/domain/GithubRepo.java index 06b56b5..fd8158d 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/domain/GithubRepo.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/domain/GithubRepo.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -25,6 +26,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") public class GithubRepo { @Id diff --git a/src/main/java/com/specialwarriors/conal/github_repo/exception/GithubRepoException.java b/src/main/java/com/specialwarriors/conal/github_repo/exception/GithubRepoException.java index ea0649f..eaf3354 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/exception/GithubRepoException.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/exception/GithubRepoException.java @@ -9,14 +9,14 @@ @RequiredArgsConstructor public enum GithubRepoException implements BaseException { - UNAUTHORIZED_GITHUBREPO_ACCESS(HttpStatus.FORBIDDEN, "리포지토리 접근 권한이 없습니다"), - NOT_FOUND_GITHUBREPO_NAME(HttpStatus.NOT_FOUND, "깃허브 리포지토리 이름을 찾을 수 없습니다"), - NOT_FOUND_GITHUBREPO(HttpStatus.NOT_FOUND, "깃허브 리포지토리를 찾을 수 없습니다"), - NOT_FOUND_GITHUBREPO_EMAIL(HttpStatus.NOT_FOUND, "기여자 이메일이 없습니다"), - EXCEED_GITHUBREPO_EMAIL(HttpStatus.BAD_REQUEST, "이메일은 5개까지 등록할 수 있습니다"), - INVALID_GITHUBREPO_URL(HttpStatus.BAD_REQUEST, "잘못된 URL 입니다."), - INVALID_GITHUBREPO_EMAIL(HttpStatus.BAD_REQUEST, "잘못된 이메일 입니다"), - INVALID_GITHUBREPO_DURATION(HttpStatus.NOT_FOUND, "종료일이 존재하지 않습니다"); + UNAUTHORIZED_GITHUB_REPO_ACCESS(HttpStatus.FORBIDDEN, "리포지토리 접근 권한이 없습니다"), + GITHUB_REPO_NAME_NOT_FOUND(HttpStatus.NOT_FOUND, "깃허브 리포지토리 이름을 찾을 수 없습니다"), + GITHUB_REPO_NOT_FOUND(HttpStatus.NOT_FOUND, "깃허브 리포지토리를 찾을 수 없습니다"), + GITHUB_REPO_EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "기여자 이메일이 없습니다"), + GITHUB_REPO_EMAIL_LIMIT_EXCEED(HttpStatus.BAD_REQUEST, "이메일은 5개까지 등록할 수 있습니다"), + INVALID_GITHUB_REPO_URL(HttpStatus.BAD_REQUEST, "잘못된 URL 입니다."), + INVALID_GITHUB_REPO_EMAIL(HttpStatus.BAD_REQUEST, "잘못된 이메일 입니다"), + INVALID_GITHUB_REPO_DURATION(HttpStatus.NOT_FOUND, "종료일이 존재하지 않습니다"); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java index ce65429..4ca6f2e 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java @@ -4,6 +4,8 @@ import com.specialwarriors.conal.github_repo.domain.GithubRepo; import com.specialwarriors.conal.github_repo.exception.GithubRepoException; import com.specialwarriors.conal.github_repo.repository.GithubRepoRepository; +import com.specialwarriors.conal.user.domain.User; +import com.specialwarriors.conal.user.service.UserQuery; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -12,23 +14,23 @@ public class GithubRepoQuery { private final GithubRepoRepository githubRepoRepository; + private final UserQuery userQuery; public GithubRepo findByUserIdAndRepositoryId(Long userId, Long repositoryId) { - GithubRepo githubRepo = githubRepoRepository.findById(repositoryId).orElseThrow(() -> - new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO) - ); + GithubRepo githubRepo = findById(repositoryId); + User user = userQuery.findById(userId); - if (!userId.equals(githubRepo.getUser().getId())) { - throw new GeneralException(GithubRepoException.UNAUTHORIZED_GITHUBREPO_ACCESS); + if (user.notHasGithubRepo(githubRepo)) { + throw new GeneralException(GithubRepoException.UNAUTHORIZED_GITHUB_REPO_ACCESS); } return githubRepo; } - public GithubRepo findByRepositoryId(long repositoryId) { + public GithubRepo findById(long repositoryId) { return githubRepoRepository.findById(repositoryId) - .orElseThrow(() -> new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO)); + .orElseThrow(() -> new GeneralException(GithubRepoException.GITHUB_REPO_NOT_FOUND)); } } diff --git a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java index 880be04..e838cc2 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java @@ -74,11 +74,11 @@ public GithubRepoCreateResponse createGithubRepo(Long userId, GithubRepoCreateRe private void validateCreateRequest(GithubRepoCreateRequest request) { if (request.name().isEmpty()) { - throw new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO_NAME); + throw new GeneralException(GithubRepoException.GITHUB_REPO_NAME_NOT_FOUND); } if (!GITHUB_URL_PATTERN.matcher(request.url()).matches()) { - throw new GeneralException(GithubRepoException.INVALID_GITHUBREPO_URL); + throw new GeneralException(GithubRepoException.INVALID_GITHUB_REPO_URL); } long validEmailCount = request.emails().stream() @@ -87,10 +87,10 @@ private void validateCreateRequest(GithubRepoCreateRequest request) { .count(); if (validEmailCount == 0) { - throw new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO_EMAIL); + throw new GeneralException(GithubRepoException.GITHUB_REPO_EMAIL_NOT_FOUND); } if (validEmailCount > 5) { - throw new GeneralException(GithubRepoException.EXCEED_GITHUBREPO_EMAIL); + throw new GeneralException(GithubRepoException.GITHUB_REPO_EMAIL_LIMIT_EXCEED); } for (String email : request.emails()) { @@ -98,14 +98,14 @@ private void validateCreateRequest(GithubRepoCreateRequest request) { if (Objects.nonNull(email)) { String trimmed = email.trim(); if (!trimmed.isEmpty() && !EMAIL_PATTERN.matcher(trimmed).matches()) { - throw new GeneralException(GithubRepoException.INVALID_GITHUBREPO_EMAIL); + throw new GeneralException(GithubRepoException.INVALID_GITHUB_REPO_EMAIL); } } } if (request.endDate() == null) { - throw new GeneralException(GithubRepoException.INVALID_GITHUBREPO_DURATION); + throw new GeneralException(GithubRepoException.INVALID_GITHUB_REPO_DURATION); } } diff --git a/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java b/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java index 06bae4f..23055fc 100644 --- a/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java +++ b/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java @@ -25,7 +25,7 @@ public class NotificationService { public void updateNotificationAgreement(long userId, long repositoryId, NotificationAgreementUpdateRequest request) { - GithubRepo githubRepo = githubRepoQuery.findByRepositoryId(repositoryId); + GithubRepo githubRepo = githubRepoQuery.findById(repositoryId); NotificationType notificationType = NotificationType.valueOf(request.type()); NotificationAgreement notificationAgreement = notificationAgreementQuery @@ -33,8 +33,9 @@ public void updateNotificationAgreement(long userId, long repositoryId, // 사용자가 자신의 github repo에 접근한 것이 맞는 지 검증 User user = userQuery.findById(userId); - if (!user.hasGithubRepo(repositoryId)) { - throw new GeneralException(GithubRepoException.UNAUTHORIZED_GITHUBREPO_ACCESS); + + if (user.notHasGithubRepo(githubRepo)) { + throw new GeneralException(GithubRepoException.UNAUTHORIZED_GITHUB_REPO_ACCESS); } if (request.isAgree()) { diff --git a/src/main/java/com/specialwarriors/conal/user/domain/User.java b/src/main/java/com/specialwarriors/conal/user/domain/User.java index 75b2c80..c189455 100644 --- a/src/main/java/com/specialwarriors/conal/user/domain/User.java +++ b/src/main/java/com/specialwarriors/conal/user/domain/User.java @@ -41,9 +41,9 @@ public User(int githubId, String username, String avatarUrl) { this.avatarUrl = avatarUrl; } - public boolean hasGithubRepo(long repositoryId) { + public boolean notHasGithubRepo(GithubRepo githubRepo) { - return githubRepos.stream().anyMatch(repo -> repo.getId() == repositoryId); + return !githubRepos.contains(githubRepo); } public void addGithubRepo(GithubRepo githubRepo) { diff --git a/src/main/java/com/specialwarriors/conal/util/UrlUtil.java b/src/main/java/com/specialwarriors/conal/util/UrlUtil.java index f06c023..1807245 100644 --- a/src/main/java/com/specialwarriors/conal/util/UrlUtil.java +++ b/src/main/java/com/specialwarriors/conal/util/UrlUtil.java @@ -19,7 +19,7 @@ public static String[] urlToOwnerAndReponame(String url) { return new String[]{owner, repoName}; } else { - throw new GeneralException(GithubRepoException.INVALID_GITHUBREPO_URL); + throw new GeneralException(GithubRepoException.INVALID_GITHUB_REPO_URL); } } diff --git a/src/main/java/com/specialwarriors/conal/vote/dto/request/VoteSaveRequest.java b/src/main/java/com/specialwarriors/conal/vote/dto/request/VoteSaveRequest.java deleted file mode 100644 index acd3d62..0000000 --- a/src/main/java/com/specialwarriors/conal/vote/dto/request/VoteSaveRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.specialwarriors.conal.vote.dto.request; - -import jakarta.validation.constraints.NotEmpty; - -public record VoteSaveRequest( - @NotEmpty(message = "투표한 이메일은 필수값입니다.") String votedEmail) { - -} diff --git a/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java b/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java index 9e09e25..67c9c35 100644 --- a/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java +++ b/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java @@ -39,7 +39,7 @@ public void openVote(long repoId) { final Date issuedAt = new Date(); final long expirationMillis = 604800000; - GithubRepo githubRepo = githubRepoQuery.findByRepositoryId(repoId); + GithubRepo githubRepo = githubRepoQuery.findById(repoId); List contributors = githubRepo.getContributors(); String[] userTokens = contributors.stream().map(Contributor::getEmail) @@ -64,7 +64,7 @@ public List findVoteTargetEmails(long repoId, String userToken) { throw new GeneralException(VoteException.UNAUTHORIZED_VOTE_ACCESS); } - GithubRepo githubRepo = githubRepoQuery.findByRepositoryId(repoId); + GithubRepo githubRepo = githubRepoQuery.findById(repoId); return githubRepo.getContributors().stream() .map(Contributor::getEmail) @@ -81,10 +81,10 @@ public List getVoteFormResponse(long repoId) { } Set userTokens = redisTemplate.opsForSet().members(voteKey); - List voteTargetEmails = userTokens.stream().map(jwtProvider::getEmailFrom).toList(); + List voteTargetEmails = userTokens.stream().map(jwtProvider::extractEmailFrom).toList(); return userTokens.stream().map(userToken -> { - String email = jwtProvider.getEmailFrom(userToken); + String email = jwtProvider.extractEmailFrom(userToken); return new VoteFormResponse(repoId, userToken, email, voteTargetEmails); }) diff --git a/src/test/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProviderTest.java b/src/test/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProviderTest.java new file mode 100644 index 0000000..aedbb94 --- /dev/null +++ b/src/test/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProviderTest.java @@ -0,0 +1,84 @@ +package com.specialwarriors.conal.common.auth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.jsonwebtoken.JwtException; +import java.util.Date; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class JwtTokenProviderTest { + + private final String JWT_SECRET = "sWfMxuTSQ4BzujHmMw71u96o+TUlanQqPIqxGBHfPz0="; + private final String EMAIL = "email@example.com"; + private final Date ISSUED_AT = new Date(); + private final long EXPIRATION_MILLIS = 604800000; + + private JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(JWT_SECRET); + + @DisplayName("투표 사용자 토큰을 생성할 수 있다.") + @Test + public void createVoteUserToken() { + // given + + // when + + // then + assertThatNoException().isThrownBy(() -> jwtTokenProvider + .createVoteUserToken(EMAIL, ISSUED_AT, EXPIRATION_MILLIS)); + } + + @Nested + @DisplayName("투표 사용자 토큰에서 이메일을 추출할 때") + class ExtractEmailFromTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + String userToken = jwtTokenProvider + .createVoteUserToken(EMAIL, ISSUED_AT, EXPIRATION_MILLIS); + + // when + String extractedEmail = jwtTokenProvider.extractEmailFrom(userToken); + + // then + assertThat(extractedEmail).isEqualTo(EMAIL); + } + + @DisplayName("유효하지 않은 포맷의 토큰일 경우 예외가 발생한다.") + @Test + public void invalidFormatToken() { + // given + String invalidToken = "invalidToken"; + + // when + + // then + assertThatThrownBy(() -> jwtTokenProvider.extractEmailFrom(invalidToken)) + .isInstanceOf(JwtException.class); + } + + @DisplayName("만료된 토큰일 경우 예외가 발생한다.") + @Test + public void expiredToken() { + // given + long issuedAtMillis = ISSUED_AT.toInstant() + .minusMillis(EXPIRATION_MILLIS + 1) + .toEpochMilli(); + Date issuedAt = new Date(issuedAtMillis); + + String expiredToken = jwtTokenProvider + .createVoteUserToken(EMAIL, issuedAt, EXPIRATION_MILLIS); + + // when + + // then + assertThatThrownBy(() -> jwtTokenProvider.extractEmailFrom(expiredToken)) + .isInstanceOf(JwtException.class); + } + } +} diff --git a/src/test/java/com/specialwarriors/conal/github_repo/service/GithubRepoQueryTest.java b/src/test/java/com/specialwarriors/conal/github_repo/service/GithubRepoQueryTest.java new file mode 100644 index 0000000..bf06f6f --- /dev/null +++ b/src/test/java/com/specialwarriors/conal/github_repo/service/GithubRepoQueryTest.java @@ -0,0 +1,154 @@ +package com.specialwarriors.conal.github_repo.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.specialwarriors.conal.common.config.QuerydslConfig; +import com.specialwarriors.conal.common.exception.GeneralException; +import com.specialwarriors.conal.github_repo.domain.GithubRepo; +import com.specialwarriors.conal.github_repo.exception.GithubRepoException; +import com.specialwarriors.conal.github_repo.repository.GithubRepoRepository; +import com.specialwarriors.conal.user.exception.UserException; +import com.specialwarriors.conal.user.repository.UserRepository; +import com.specialwarriors.conal.user.service.UserQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; + +@ActiveProfiles("test") +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) +@DataJpaTest +@Import(QuerydslConfig.class) +class GithubRepoQueryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private GithubRepoRepository githubRepoRepository; + + private GithubRepoQuery githubRepoQuery; + + @BeforeEach + void init() { + UserQuery userQuery = new UserQuery(userRepository); + githubRepoQuery = new GithubRepoQuery(githubRepoRepository, userQuery); + } + + @Nested + @DisplayName("ID로 Github Repository를 조회할 때 ") + @Sql(scripts = "/sql/github_repo/service/find_by_repository_id_test_setup.sql", + executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) + class FindByIdTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + long repositoryId = 1L; + + // when + GithubRepo githubRepo = githubRepoQuery.findById(repositoryId); + + // then + assertThat(githubRepo.getId()).isEqualTo(repositoryId); + } + + @DisplayName("존재하지 않는 Repository일 경우 예외가 발생한다.") + @Test + public void githubRepoNotFound() { + // given + long repositoryId = 2L; + + // when + + // then + assertThatThrownBy(() -> githubRepoQuery.findById(repositoryId)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(GithubRepoException.GITHUB_REPO_NOT_FOUND); + } + } + + @Nested + @DisplayName("사용자 ID와 Github Repository ID로 Github Repository를 조회할 때 ") + @Sql(scripts = "/sql/github_repo/service/find_by_user_id_and_repository_id_test_setup.sql", + executionPhase = ExecutionPhase.BEFORE_TEST_METHOD) + class FindByUserIdAndRepositoryIdTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + long userId = 1L; + long repositoryId = 1L; + + // when + GithubRepo githubRepo = githubRepoQuery.findByUserIdAndRepositoryId(userId, + repositoryId); + + // then + assertThat(githubRepo.getId()).isEqualTo(repositoryId); + } + + @DisplayName("존재하지 않는 Github Repository일 경우 예외가 발생한다.") + @Test + public void githubRepoNotFound() { + // given + long userId = 1L; + long repositoryId = 3L; + + // when + + // then + assertThatThrownBy(() -> + githubRepoQuery.findByUserIdAndRepositoryId(userId, repositoryId)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(GithubRepoException.GITHUB_REPO_NOT_FOUND); + } + + @DisplayName("존재하지 않는 사용자일 경우 예외가 발생한다.") + @Test + public void userNotFound() { + // given + long userId = 3L; + long repositoryId = 1L; + + // when + + // then + assertThatThrownBy( + () -> githubRepoQuery.findByUserIdAndRepositoryId(userId, repositoryId)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(UserException.USER_NOT_FOUND); + } + + @DisplayName("사용자의 Github Repository가 아닐 경우 예외가 발생한다.") + @Test + public void unauthorizedGithubRepoAccess() { + // given + long userId = 1L; + long repositoryId = 2L; + + // when + + // then + assertThatThrownBy(() -> + githubRepoQuery.findByUserIdAndRepositoryId(userId, repositoryId)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(GithubRepoException.UNAUTHORIZED_GITHUB_REPO_ACCESS); + } + } +} diff --git a/src/test/java/com/specialwarriors/conal/vote/service/VoteServiceTest.java b/src/test/java/com/specialwarriors/conal/vote/service/VoteServiceTest.java new file mode 100644 index 0000000..f9a6f3e --- /dev/null +++ b/src/test/java/com/specialwarriors/conal/vote/service/VoteServiceTest.java @@ -0,0 +1,458 @@ +package com.specialwarriors.conal.vote.service; + +import static com.specialwarriors.conal.github_repo.exception.GithubRepoException.GITHUB_REPO_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.when; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.specialwarriors.conal.common.auth.jwt.JwtTokenProvider; +import com.specialwarriors.conal.common.exception.GeneralException; +import com.specialwarriors.conal.contributor.domain.Contributor; +import com.specialwarriors.conal.github_repo.domain.GithubRepo; +import com.specialwarriors.conal.github_repo.service.GithubRepoQuery; +import com.specialwarriors.conal.vote.dto.request.VoteSubmitRequest; +import com.specialwarriors.conal.vote.dto.response.VoteFormResponse; +import com.specialwarriors.conal.vote.dto.response.VoteResultResponse; +import com.specialwarriors.conal.vote.exception.VoteException; +import io.jsonwebtoken.JwtException; +import java.time.Duration; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; + +@ExtendWith(MockitoExtension.class) +class VoteServiceTest { + + private final long REPO_ID = 1L; + private final List EMAILS = List.of("mj3242@naver.com", + "mj1111@naver.com", + "mj2222@naver.com", + "mj3333@naver.com"); + private final String USER_TOKEN = "header.payload.signature"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private GithubRepoQuery githubRepoQuery; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @InjectMocks + private VoteService voteService; + + @Nested + @DisplayName("투표를 오픈할 때 ") + class OpenVoteTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + GithubRepo githubRepo = mock(GithubRepo.class); + when(githubRepoQuery.findById(REPO_ID)).thenReturn(githubRepo); + + List contributors = IntStream.range(0, 4) + .mapToObj(i -> mock(Contributor.class)) + .toList(); + for (int i = 0; i < contributors.size(); i++) { + when(contributors.get(i).getEmail()).thenReturn(EMAILS.get(i)); + } + + when(githubRepo.getContributors()).thenReturn(contributors); + + when(jwtTokenProvider.createVoteUserToken(any(String.class), + any(Date.class), anyLong())) + .thenReturn(USER_TOKEN); + + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + + // when + voteService.openVote(REPO_ID); + + // then + verify(githubRepoQuery).findById(REPO_ID); + verify(jwtTokenProvider, times(contributors.size())) + .createVoteUserToken(any(String.class), any(Date.class), anyLong()); + verify(setOperations).add(any(String.class), any(String[].class)); + verify(redisTemplate).expire(any(String.class), any(Duration.class)); + } + + @DisplayName("Github Repository가 존재하지 않을 경우 예외가 발생한다.") + @Test + public void githubRepoNotFound() { + // given + GeneralException exception = new GeneralException(GITHUB_REPO_NOT_FOUND); + when(githubRepoQuery.findById(REPO_ID)).thenThrow(exception); + + // when + + // then + assertThatThrownBy(() -> voteService.openVote(REPO_ID)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(GITHUB_REPO_NOT_FOUND); + } + } + + @Nested + @DisplayName("투표 대상 이메일 조회 시 ") + class FindVoteTargetEmailsTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = IntStream.range(0, 4) + .mapToObj(i -> USER_TOKEN) + .collect(Collectors.toSet()); + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + GithubRepo githubRepo = mock(GithubRepo.class); + when(githubRepoQuery.findById(REPO_ID)).thenReturn(githubRepo); + + List contributors = IntStream.range(0, 4) + .mapToObj(i -> mock(Contributor.class)) + .toList(); + for (int i = 0; i < contributors.size(); i++) { + when(contributors.get(i).getEmail()).thenReturn(EMAILS.get(i)); + } + when(githubRepo.getContributors()).thenReturn(contributors); + + // when + List emails = voteService.findVoteTargetEmails(REPO_ID, USER_TOKEN); + + // then + assertThat(emails).hasSize(contributors.size()) + .containsAll(EMAILS); + } + + @DisplayName("투표 오픈 내역이 존재하지 않을 경우 예외가 발생한다.") + @Test + public void voteNotFound() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(false); + + // when + + // then + + assertThatThrownBy(() -> voteService.findVoteTargetEmails(REPO_ID, USER_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(VoteException.VOTE_NOT_FOUND); + } + + @DisplayName("투표에 접근할 수 없는 사용자일 경우 예외가 발생한다.") + @Test + public void unauthorizedVoteAccess() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = Collections.EMPTY_SET; + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + // when + + // then + assertThatThrownBy(() -> voteService.findVoteTargetEmails(REPO_ID, USER_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(VoteException.UNAUTHORIZED_VOTE_ACCESS); + } + + @DisplayName("Github Repository가 존재하지 않을 경우 예외가 발생한다.") + @Test + public void githubRepoNotFound() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = mock(Set.class); + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + when(userTokens.contains(any(String.class))).thenReturn(true); + + GeneralException exception = new GeneralException(GITHUB_REPO_NOT_FOUND); + when(githubRepoQuery.findById(REPO_ID)).thenThrow(exception); + + // when + + // then + assertThatThrownBy(() -> voteService.findVoteTargetEmails(REPO_ID, USER_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(GITHUB_REPO_NOT_FOUND); + } + } + + @Nested + @DisplayName("투표 폼 구성 데이터 제공 시 ") + class GetVoteFormResponseTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = new HashSet<>(); + for (int i = 0; i < EMAILS.size(); i++) { + String userToken = USER_TOKEN + i; + userTokens.add(userToken); + when(jwtTokenProvider.extractEmailFrom(userToken)).thenReturn(EMAILS.get(i)); + } + + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + // when + List responses = voteService.getVoteFormResponse(REPO_ID); + + // then + assertThat(responses).hasSize(userTokens.size()) + .allMatch(response -> response.repoId() == REPO_ID) + .allMatch(response -> EMAILS.contains(response.email())) + .allMatch(response -> userTokens.contains(response.userToken())) + .allMatch(response -> EMAILS.containsAll(response.voteTargetEmails())); + } + + @DisplayName("투표 오픈 내역이 존재하지 않을 경우 예외가 발생한다.") + @Test + public void voteNotFound() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(false); + + // when + + // then + assertThatThrownBy(() -> voteService.getVoteFormResponse(REPO_ID)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(VoteException.VOTE_NOT_FOUND); + } + + @DisplayName("유효하지 않은 사용자 토큰이 존재할 경우 예외가 발생한다.") + @Test + public void invalidUserToken() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = IntStream.range(0, 4) + .mapToObj(i -> USER_TOKEN + i) + .collect(Collectors.toSet()); + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + when(jwtTokenProvider.extractEmailFrom(any(String.class))) + .thenThrow(new JwtException("invalid user token")); + + // when + + // then + assertThatThrownBy(() -> voteService.getVoteFormResponse(REPO_ID)) + .isInstanceOf(JwtException.class); + } + } + + @Nested + @DisplayName("투표 요청 저장 시 ") + class SaveVoteRequestTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = IntStream.range(0, 4) + .mapToObj(i -> USER_TOKEN + i) + .collect(Collectors.toSet()); + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + HashOperations hashOperations = mock(HashOperations.class); + when(hashOperations.hasKey(any(String.class), any(String.class))).thenReturn(false); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + + String userToken = userTokens.stream().findFirst().get(); + String votedEmail = EMAILS.get(0); + VoteSubmitRequest request = new VoteSubmitRequest(REPO_ID, userToken, votedEmail); + + // when + boolean result = voteService.saveVoteRequest(REPO_ID, request); + + // then + assertThat(result).isTrue(); + verify(hashOperations).put(any(String.class), eq(userToken), eq(votedEmail)); + verify(redisTemplate).expire(any(String.class), any(Duration.class)); + } + + @DisplayName("이미 투표한 경우 false를 반환한다.") + @Test + public void alreadyVoted() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = IntStream.range(0, 4) + .mapToObj(i -> USER_TOKEN + i) + .collect(Collectors.toSet()); + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + HashOperations hashOperations = mock(HashOperations.class); + when(hashOperations.hasKey(any(String.class), any(String.class))).thenReturn(true); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + + String userToken = userTokens.stream().findFirst().get(); + String votedEmail = EMAILS.get(0); + VoteSubmitRequest request = new VoteSubmitRequest(REPO_ID, userToken, votedEmail); + + // when + boolean result = voteService.saveVoteRequest(REPO_ID, request); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("투표 오픈 내역이 존재하지 않을 경우 예외가 발생한다.") + @Test + public void voteNotFound() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(false); + + String votedEmail = EMAILS.get(0); + VoteSubmitRequest request = new VoteSubmitRequest(REPO_ID, USER_TOKEN, votedEmail); + + // when + + // then + assertThatThrownBy(() -> voteService.saveVoteRequest(REPO_ID, request)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(VoteException.VOTE_NOT_FOUND); + } + + @DisplayName("투표에 접근할 수 없는 사용자일 경우 예외가 발생한다.") + @Test + public void unauthorizedVoteAccess() { + // given + when(redisTemplate.hasKey(any(String.class))).thenReturn(true); + + Set userTokens = Collections.EMPTY_SET; + SetOperations setOperations = mock(SetOperations.class); + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.members(any(String.class))).thenReturn(userTokens); + + String votedEmail = EMAILS.get(0); + VoteSubmitRequest request = new VoteSubmitRequest(REPO_ID, USER_TOKEN, votedEmail); + + // when + + // then + assertThatThrownBy(() -> voteService.saveVoteRequest(REPO_ID, request)) + .isInstanceOf(GeneralException.class) + .extracting("exception") + .isEqualTo(VoteException.UNAUTHORIZED_VOTE_ACCESS); + } + } + + @DisplayName("투표 결과를 저장할 때 성공한다.") + @Test + public void saveVoteResult() { + // given + VoteSubmitRequest request = new VoteSubmitRequest(REPO_ID, USER_TOKEN, EMAILS.get(0)); + + HashOperations hashOperations = mock(HashOperations.class); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + + // when + voteService.saveVoteResult(REPO_ID, request); + + // then + verify(hashOperations).increment(any(String.class), eq(request.votedEmail()), eq(1L)); + verify(redisTemplate).expire(any(String.class), any(Duration.class)); + } + + @Nested + @DisplayName("투표 결과를 조회할 때 ") + class GetVoteResultTest { + + @DisplayName("성공한다.") + @Test + public void success() { + // given + Map entries = Map.of( + EMAILS.get(0), 1, + EMAILS.get(1), 0, + EMAILS.get(2), 1, + EMAILS.get(3), 2 + ); + + HashOperations hashOperations = mock(HashOperations.class); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.entries(any(String.class))).thenReturn(entries); + + // when + VoteResultResponse response = voteService.getVoteResult(REPO_ID); + + // then + assertThat(response.items()).hasSize(entries.size()) + .extracting("email", "votes") + .containsExactlyInAnyOrder(tuple(EMAILS.get(0), 1), + tuple(EMAILS.get(1), 0), + tuple(EMAILS.get(2), 1), + tuple(EMAILS.get(3), 2)); + } + + @DisplayName("투표 참여 내역이 없을 경우 빈 List를 포함한 응답을 반환한다.") + @Test + public void noVoteParticipants() { + // given + HashOperations hashOperations = mock(HashOperations.class); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.entries(any(String.class))).thenReturn(new HashMap<>()); + + // when + VoteResultResponse response = voteService.getVoteResult(REPO_ID); + + // then + assertThat(response.items()).isEmpty(); + } + } +} diff --git a/src/test/resources/sql/github_repo/service/find_by_repository_id_test_setup.sql b/src/test/resources/sql/github_repo/service/find_by_repository_id_test_setup.sql new file mode 100644 index 0000000..6f648f4 --- /dev/null +++ b/src/test/resources/sql/github_repo/service/find_by_repository_id_test_setup.sql @@ -0,0 +1,5 @@ +insert into users(id, github_id, username, avatar_url) +values (1, 1, 'username', 'avatar_url'); + +insert into github_repos(id, name, url, end_date, user_id) +values (1, 'repo', 'repo_url', now(), 1); \ No newline at end of file diff --git a/src/test/resources/sql/github_repo/service/find_by_user_id_and_repository_id_test_setup.sql b/src/test/resources/sql/github_repo/service/find_by_user_id_and_repository_id_test_setup.sql new file mode 100644 index 0000000..fea8273 --- /dev/null +++ b/src/test/resources/sql/github_repo/service/find_by_user_id_and_repository_id_test_setup.sql @@ -0,0 +1,7 @@ +insert into users(id, github_id, username, avatar_url) +values (1, 1, 'username', 'avatar_url'), + (2, 2, 'username', 'avatar_url'); + +insert into github_repos(id, name, url, end_date, user_id) +values (1, 'repo', 'repo_url', now(), 1), + (2, 'repo2', 'repo_url', now(), 2);